diff --git a/CHANGES.md b/CHANGES.md index 85819b4604..8a193e762c 100644 --- a/CHANGES.md +++ b/CHANGES.md @@ -2,7 +2,7 @@ Changes in Element 1.1.7 (2021-XX-XX) =================================================== Features ✨: - - + - Spaces beta Improvements 🙌: - Add ability to install APK from directly from Element (#2381) diff --git a/matrix-sdk-android-rx/src/main/java/org/matrix/android/sdk/rx/RxSession.kt b/matrix-sdk-android-rx/src/main/java/org/matrix/android/sdk/rx/RxSession.kt index 0fe2b01576..67a35cac2e 100644 --- a/matrix-sdk-android-rx/src/main/java/org/matrix/android/sdk/rx/RxSession.kt +++ b/matrix-sdk-android-rx/src/main/java/org/matrix/android/sdk/rx/RxSession.kt @@ -39,6 +39,7 @@ import org.matrix.android.sdk.api.session.room.members.ChangeMembershipState import org.matrix.android.sdk.api.session.room.model.RoomMemberSummary import org.matrix.android.sdk.api.session.room.model.RoomSummary import org.matrix.android.sdk.api.session.room.model.create.CreateRoomParams +import org.matrix.android.sdk.api.session.space.SpaceSummaryQueryParams import org.matrix.android.sdk.api.session.sync.SyncState import org.matrix.android.sdk.api.session.user.model.User import org.matrix.android.sdk.api.session.widgets.model.Widget @@ -66,6 +67,13 @@ class RxSession(private val session: Session) { } } + fun liveSpaceSummaries(queryParams: SpaceSummaryQueryParams): Observable> { + return session.spaceService().getSpaceSummariesLive(queryParams).asObservable() + .startWithCallable { + session.spaceService().getSpaceSummaries(queryParams) + } + } + fun liveBreadcrumbs(queryParams: RoomSummaryQueryParams): Observable> { return session.getBreadcrumbsLive(queryParams).asObservable() .startWithCallable { diff --git a/matrix-sdk-android/src/androidTest/java/org/matrix/android/sdk/session/space/SpaceCreationTest.kt b/matrix-sdk-android/src/androidTest/java/org/matrix/android/sdk/session/space/SpaceCreationTest.kt new file mode 100644 index 0000000000..278762671b --- /dev/null +++ b/matrix-sdk-android/src/androidTest/java/org/matrix/android/sdk/session/space/SpaceCreationTest.kt @@ -0,0 +1,194 @@ +/* + * Copyright 2021 The Matrix.org Foundation C.I.C. + * + * 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 org.matrix.android.sdk.session.space + +import kotlinx.coroutines.delay +import kotlinx.coroutines.runBlocking +import org.junit.FixMethodOrder +import org.junit.Test +import org.junit.runner.RunWith +import org.junit.runners.JUnit4 +import org.junit.runners.MethodSorters +import org.matrix.android.sdk.InstrumentedTest +import org.matrix.android.sdk.api.query.QueryStringValue +import org.matrix.android.sdk.api.session.events.model.EventType +import org.matrix.android.sdk.api.session.events.model.toModel +import org.matrix.android.sdk.api.session.room.RoomSummaryQueryParams +import org.matrix.android.sdk.api.session.room.model.GuestAccess +import org.matrix.android.sdk.api.session.room.model.Membership +import org.matrix.android.sdk.api.session.room.model.PowerLevelsContent +import org.matrix.android.sdk.api.session.room.model.RoomGuestAccessContent +import org.matrix.android.sdk.api.session.room.model.RoomHistoryVisibility +import org.matrix.android.sdk.api.session.room.model.RoomHistoryVisibilityContent +import org.matrix.android.sdk.api.session.room.model.RoomType +import org.matrix.android.sdk.api.session.room.model.create.CreateRoomParams +import org.matrix.android.sdk.api.session.room.model.create.CreateRoomPreset +import org.matrix.android.sdk.api.session.room.model.create.RoomCreateContent +import org.matrix.android.sdk.api.session.room.roomSummaryQueryParams +import org.matrix.android.sdk.api.session.space.JoinSpaceResult +import org.matrix.android.sdk.common.CommonTestHelper +import org.matrix.android.sdk.common.SessionTestParams +import kotlin.test.assertEquals +import kotlin.test.assertNotNull + +@RunWith(JUnit4::class) +@FixMethodOrder(MethodSorters.JVM) +class SpaceCreationTest : InstrumentedTest { + + private val commonTestHelper = CommonTestHelper(context()) + + @Test + fun createSimplePublicSpace() { + val session = commonTestHelper.createAccount("Hubble", SessionTestParams(true)) + val roomName = "My Space" + val topic = "A public space for test" + val spaceId: String + runBlocking { + spaceId = session.spaceService().createSpace(roomName, topic, null, true) + // wait a bit to let the summary update it self :/ + delay(400) + } + + val syncedSpace = session.spaceService().getSpace(spaceId) + assertEquals(roomName, syncedSpace?.asRoom()?.roomSummary()?.name, "Room name should be set") + assertEquals(topic, syncedSpace?.asRoom()?.roomSummary()?.topic, "Room topic should be set") + // assertEquals(topic, syncedSpace.asRoom().roomSummary()?., "Room topic should be set") + + assertNotNull(syncedSpace, "Space should be found by Id") + val creationEvent = syncedSpace.asRoom().getStateEvent(EventType.STATE_ROOM_CREATE) + val createContent = creationEvent?.content.toModel() + assertEquals(RoomType.SPACE, createContent?.type, "Room type should be space") + + var powerLevelsContent: PowerLevelsContent? = null + commonTestHelper.waitWithLatch { latch -> + commonTestHelper.retryPeriodicallyWithLatch(latch) { + val toModel = syncedSpace.asRoom().getStateEvent(EventType.STATE_ROOM_POWER_LEVELS)?.content.toModel() + powerLevelsContent = toModel + toModel != null + } + } + assertEquals(100, powerLevelsContent?.eventsDefault, "Space-rooms should be created with a power level for events_default of 100") + + val guestAccess = syncedSpace.asRoom().getStateEvent(EventType.STATE_ROOM_GUEST_ACCESS)?.content + ?.toModel()?.guestAccess + + assertEquals(GuestAccess.CanJoin, guestAccess, "Public space room should be peekable by guest") + + val historyVisibility = syncedSpace.asRoom().getStateEvent(EventType.STATE_ROOM_HISTORY_VISIBILITY)?.content + ?.toModel()?.historyVisibility + + assertEquals(RoomHistoryVisibility.WORLD_READABLE, historyVisibility, "Public space room should be world readable") + + commonTestHelper.signOutAndClose(session) + } + + @Test + fun testJoinSimplePublicSpace() { + val aliceSession = commonTestHelper.createAccount("alice", SessionTestParams(true)) + val bobSession = commonTestHelper.createAccount("bob", SessionTestParams(true)) + + val roomName = "My Space" + val topic = "A public space for test" + val spaceId: String + runBlocking { + spaceId = aliceSession.spaceService().createSpace(roomName, topic, null, true) + // wait a bit to let the summary update it self :/ + delay(400) + } + + // Try to join from bob, it's a public space no need to invite + + val joinResult: JoinSpaceResult + runBlocking { + joinResult = bobSession.spaceService().joinSpace(spaceId) + } + + assertEquals(JoinSpaceResult.Success, joinResult) + + val spaceBobPov = bobSession.spaceService().getSpace(spaceId) + assertEquals(roomName, spaceBobPov?.asRoom()?.roomSummary()?.name, "Room name should be set") + assertEquals(topic, spaceBobPov?.asRoom()?.roomSummary()?.topic, "Room topic should be set") + + commonTestHelper.signOutAndClose(aliceSession) + commonTestHelper.signOutAndClose(bobSession) + } + + @Test + fun testSimplePublicSpaceWithChildren() { + val aliceSession = commonTestHelper.createAccount("alice", SessionTestParams(true)) + val bobSession = commonTestHelper.createAccount("bob", SessionTestParams(true)) + + val roomName = "My Space" + val topic = "A public space for test" + + val spaceId: String = runBlocking { aliceSession.spaceService().createSpace(roomName, topic, null, true) } + val syncedSpace = aliceSession.spaceService().getSpace(spaceId) + + // create a room + val firstChild: String = runBlocking { + aliceSession.createRoom(CreateRoomParams().apply { + this.name = "FirstRoom" + this.topic = "Description of first room" + this.preset = CreateRoomPreset.PRESET_PUBLIC_CHAT + }) + } + + runBlocking { + syncedSpace?.addChildren(firstChild, listOf(aliceSession.sessionParams.homeServerHost ?: ""), "a", true) + } + + val secondChild: String = runBlocking { + aliceSession.createRoom(CreateRoomParams().apply { + this.name = "SecondRoom" + this.topic = "Description of second room" + this.preset = CreateRoomPreset.PRESET_PUBLIC_CHAT + }) + } + + runBlocking { + syncedSpace?.addChildren(secondChild, listOf(aliceSession.sessionParams.homeServerHost ?: ""), "b", false) + } + + // Try to join from bob, it's a public space no need to invite + + val joinResult = runBlocking { + bobSession.spaceService().joinSpace(spaceId) + } + + assertEquals(JoinSpaceResult.Success, joinResult) + + val spaceBobPov = bobSession.spaceService().getSpace(spaceId) + assertEquals(roomName, spaceBobPov?.asRoom()?.roomSummary()?.name, "Room name should be set") + assertEquals(topic, spaceBobPov?.asRoom()?.roomSummary()?.topic, "Room topic should be set") + + // check if bob has joined automatically the first room + + val bobMembershipFirstRoom = bobSession.getRoom(firstChild)?.roomSummary()?.membership + assertEquals(Membership.JOIN, bobMembershipFirstRoom, "Bob should have joined this room") + RoomSummaryQueryParams.Builder() + + val spaceSummaryBobPov = bobSession.spaceService().getSpaceSummaries(roomSummaryQueryParams { + this.roomId = QueryStringValue.Equals(spaceId) + this.memberships = listOf(Membership.JOIN) + }).firstOrNull() + + assertEquals(2, spaceSummaryBobPov?.spaceChildren?.size ?: -1, "Unexpected number of children") + + commonTestHelper.signOutAndClose(aliceSession) + commonTestHelper.signOutAndClose(bobSession) + } +} diff --git a/matrix-sdk-android/src/androidTest/java/org/matrix/android/sdk/session/space/SpaceHierarchyTest.kt b/matrix-sdk-android/src/androidTest/java/org/matrix/android/sdk/session/space/SpaceHierarchyTest.kt new file mode 100644 index 0000000000..2fed7e338e --- /dev/null +++ b/matrix-sdk-android/src/androidTest/java/org/matrix/android/sdk/session/space/SpaceHierarchyTest.kt @@ -0,0 +1,414 @@ +/* + * Copyright 2021 The Matrix.org Foundation C.I.C. + * + * 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 org.matrix.android.sdk.session.space + +import android.util.Log +import androidx.lifecycle.Observer +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.GlobalScope +import kotlinx.coroutines.delay +import kotlinx.coroutines.launch +import kotlinx.coroutines.runBlocking +import org.junit.FixMethodOrder +import org.junit.Test +import org.junit.runner.RunWith +import org.junit.runners.JUnit4 +import org.junit.runners.MethodSorters +import org.matrix.android.sdk.InstrumentedTest +import org.matrix.android.sdk.api.session.Session +import org.matrix.android.sdk.api.session.room.model.RoomSummary +import org.matrix.android.sdk.api.session.room.model.RoomType +import org.matrix.android.sdk.api.session.room.model.create.CreateRoomParams +import org.matrix.android.sdk.api.session.room.roomSummaryQueryParams +import org.matrix.android.sdk.common.CommonTestHelper +import org.matrix.android.sdk.common.SessionTestParams +import kotlin.test.assertEquals +import kotlin.test.assertNotNull +import kotlin.test.assertTrue + +@RunWith(JUnit4::class) +@FixMethodOrder(MethodSorters.JVM) +class SpaceHierarchyTest : InstrumentedTest { + + private val commonTestHelper = CommonTestHelper(context()) + + @Test + fun createCanonicalChildRelation() { + val session = commonTestHelper.createAccount("John", SessionTestParams(true)) + val spaceName = "My Space" + val topic = "A public space for test" + val spaceId: String + runBlocking { + spaceId = session.spaceService().createSpace(spaceName, topic, null, true) + // wait a bit to let the summary update it self :/ + delay(400) + } + + val syncedSpace = session.spaceService().getSpace(spaceId) + + val roomId = runBlocking { + session.createRoom(CreateRoomParams().apply { name = "General" }) + } + + val viaServers = listOf(session.sessionParams.homeServerHost ?: "") + + runBlocking { + syncedSpace!!.addChildren(roomId, viaServers, null, true) + } + + runBlocking { + session.spaceService().setSpaceParent(roomId, spaceId, true, viaServers) + } + + Thread.sleep(9000) + + val parents = session.getRoom(roomId)?.roomSummary()?.spaceParents + val canonicalParents = session.getRoom(roomId)?.roomSummary()?.spaceParents?.filter { it.canonical == true } + + parents?.forEach { + Log.d("## TEST", "parent : $it") + } + + assertNotNull(parents) + assertEquals(1, parents.size) + assertEquals(spaceName, parents.first().roomSummary?.name) + + assertNotNull(canonicalParents) + assertEquals(1, canonicalParents.size) + assertEquals(spaceName, canonicalParents.first().roomSummary?.name) + } + + @Test + fun testCreateChildRelations() { + val session = commonTestHelper.createAccount("Jhon", SessionTestParams(true)) + val spaceName = "My Space" + val topic = "A public space for test" + Log.d("## TEST", "Before") + val spaceId = runBlocking { + session.spaceService().createSpace(spaceName, topic, null, true) + } + + Log.d("## TEST", "created space $spaceId ${Thread.currentThread()}") + val syncedSpace = session.spaceService().getSpace(spaceId) + + val children = listOf("General" to true /*canonical*/, "Random" to false) + + val roomIdList = children.map { + runBlocking { + session.createRoom(CreateRoomParams().apply { name = it.first }) + } to it.second + } + + val viaServers = listOf(session.sessionParams.homeServerHost ?: "") + + runBlocking { + roomIdList.forEach { entry -> + syncedSpace!!.addChildren(entry.first, viaServers, null, true) + } + } + + runBlocking { + roomIdList.forEach { + session.spaceService().setSpaceParent(it.first, spaceId, it.second, viaServers) + } + delay(400) + } + + roomIdList.forEach { + val parents = session.getRoom(it.first)?.roomSummary()?.spaceParents + val canonicalParents = session.getRoom(it.first)?.roomSummary()?.spaceParents?.filter { it.canonical == true } + + assertNotNull(parents) + assertEquals(1, parents.size, "Unexpected number of parent") + assertEquals(spaceName, parents.first().roomSummary?.name, "Unexpected parent name ") + assertEquals(if (it.second) 1 else 0, canonicalParents?.size ?: 0, "Parent of ${it.first} should be canonical ${it.second}") + } + } + + @Test + fun testFilteringBySpace() { + val session = commonTestHelper.createAccount("John", SessionTestParams(true)) + + val spaceAInfo = createPublicSpace(session, "SpaceA", listOf( + Triple("A1", true /*auto-join*/, true/*canonical*/), + Triple("A2", true, true) + )) + + val spaceBInfo = createPublicSpace(session, "SpaceB", listOf( + Triple("B1", true /*auto-join*/, true/*canonical*/), + Triple("B2", true, true), + Triple("B3", true, true) + )) + + val spaceCInfo = createPublicSpace(session, "SpaceC", listOf( + Triple("C1", true /*auto-join*/, true/*canonical*/), + Triple("C2", true, true) + )) + + // add C as a subspace of A + val spaceA = session.spaceService().getSpace(spaceAInfo.spaceId) + val viaServers = listOf(session.sessionParams.homeServerHost ?: "") + runBlocking { + spaceA!!.addChildren(spaceCInfo.spaceId, viaServers, null, true) + session.spaceService().setSpaceParent(spaceCInfo.spaceId, spaceAInfo.spaceId, true, viaServers) + } + + // Create orphan rooms + + val orphan1 = runBlocking { + session.createRoom(CreateRoomParams().apply { name = "O1" }) + } + val orphan2 = runBlocking { + session.createRoom(CreateRoomParams().apply { name = "O2" }) + } + + val allRooms = session.getRoomSummaries(roomSummaryQueryParams { excludeType = listOf(RoomType.SPACE) }) + + assertEquals(9, allRooms.size, "Unexpected number of rooms") + + val orphans = session.getFlattenRoomSummaryChildrenOf(null) + + assertEquals(2, orphans.size, "Unexpected number of orphan rooms") + assertTrue(orphans.indexOfFirst { it.roomId == orphan1 } != -1, "O1 should be an orphan") + assertTrue(orphans.indexOfFirst { it.roomId == orphan2 } != -1, "O2 should be an orphan ${orphans.map { it.name }}") + + val aChildren = session.getFlattenRoomSummaryChildrenOf(spaceAInfo.spaceId) + + assertEquals(4, aChildren.size, "Unexpected number of flatten child rooms") + assertTrue(aChildren.indexOfFirst { it.name == "A1" } != -1, "A1 should be a child of A") + assertTrue(aChildren.indexOfFirst { it.name == "A2" } != -1, "A2 should be a child of A") + assertTrue(aChildren.indexOfFirst { it.name == "C1" } != -1, "CA should be a grand child of A") + assertTrue(aChildren.indexOfFirst { it.name == "C2" } != -1, "A1 should be a grand child of A") + + // Add a non canonical child and check that it does not appear as orphan + val a3 = runBlocking { + session.createRoom(CreateRoomParams().apply { name = "A3" }) + } + runBlocking { + spaceA!!.addChildren(a3, viaServers, null, false) + delay(400) + // here we do not set the parent!! + } + + val orphansUpdate = session.getFlattenRoomSummaryChildrenOf(null) + assertEquals(2, orphansUpdate.size, "Unexpected number of orphan rooms ${orphansUpdate.map { it.name }}") + } + + @Test + fun testBreakCycle() { + val session = commonTestHelper.createAccount("John", SessionTestParams(true)) + + val spaceAInfo = createPublicSpace(session, "SpaceA", listOf( + Triple("A1", true /*auto-join*/, true/*canonical*/), + Triple("A2", true, true) + )) + + val spaceCInfo = createPublicSpace(session, "SpaceC", listOf( + Triple("C1", true /*auto-join*/, true/*canonical*/), + Triple("C2", true, true) + )) + + // add C as a subspace of A + val spaceA = session.spaceService().getSpace(spaceAInfo.spaceId) + val viaServers = listOf(session.sessionParams.homeServerHost ?: "") + runBlocking { + spaceA!!.addChildren(spaceCInfo.spaceId, viaServers, null, true) + session.spaceService().setSpaceParent(spaceCInfo.spaceId, spaceAInfo.spaceId, true, viaServers) + } + + // add back A as subspace of C + runBlocking { + val spaceC = session.spaceService().getSpace(spaceCInfo.spaceId) + spaceC!!.addChildren(spaceAInfo.spaceId, viaServers, null, true) + } + + Thread.sleep(1000) + + // A -> C -> A + + val aChildren = session.getFlattenRoomSummaryChildrenOf(spaceAInfo.spaceId) + + assertEquals(4, aChildren.size, "Unexpected number of flatten child rooms ${aChildren.map { it.name }}") + assertTrue(aChildren.indexOfFirst { it.name == "A1" } != -1, "A1 should be a child of A") + assertTrue(aChildren.indexOfFirst { it.name == "A2" } != -1, "A2 should be a child of A") + assertTrue(aChildren.indexOfFirst { it.name == "C1" } != -1, "CA should be a grand child of A") + assertTrue(aChildren.indexOfFirst { it.name == "C2" } != -1, "A1 should be a grand child of A") + } + + @Test + fun testLiveFlatChildren() { + val session = commonTestHelper.createAccount("John", SessionTestParams(true)) + + val spaceAInfo = createPublicSpace(session, "SpaceA", listOf( + Triple("A1", true /*auto-join*/, true/*canonical*/), + Triple("A2", true, true) + )) + + val spaceBInfo = createPublicSpace(session, "SpaceB", listOf( + Triple("B1", true /*auto-join*/, true/*canonical*/), + Triple("B2", true, true), + Triple("B3", true, true) + )) + + // add B as a subspace of A + val spaceA = session.spaceService().getSpace(spaceAInfo.spaceId) + val viaServers = listOf(session.sessionParams.homeServerHost ?: "") + runBlocking { + spaceA!!.addChildren(spaceBInfo.spaceId, viaServers, null, true) + session.spaceService().setSpaceParent(spaceBInfo.spaceId, spaceAInfo.spaceId, true, viaServers) + } + + val flatAChildren = runBlocking(Dispatchers.Main) { + session.getFlattenRoomSummaryChildrenOfLive(spaceAInfo.spaceId) + } + + commonTestHelper.waitWithLatch { latch -> + + val childObserver = object : Observer> { + override fun onChanged(children: List?) { +// Log.d("## TEST", "Space A flat children update : ${children?.map { it.name }}") + System.out.println("## TEST | Space A flat children update : ${children?.map { it.name }}") + if (children?.indexOfFirst { it.name == "C1" } != -1 + && children?.indexOfFirst { it.name == "C2" } != -1 + ) { + // B1 has been added live! + latch.countDown() + flatAChildren.removeObserver(this) + } + } + } + + val spaceCInfo = createPublicSpace(session, "SpaceC", listOf( + Triple("C1", true /*auto-join*/, true/*canonical*/), + Triple("C2", true, true) + )) + + // add C as subspace of B + runBlocking { + val spaceB = session.spaceService().getSpace(spaceBInfo.spaceId) + spaceB!!.addChildren(spaceCInfo.spaceId, viaServers, null, true) + } + + // C1 and C2 should be in flatten child of A now + + GlobalScope.launch(Dispatchers.Main) { flatAChildren.observeForever(childObserver) } + } + + // Test part one of the rooms + + val bRoomId = spaceBInfo.roomIds.first() + val bRoom = session.getRoom(bRoomId) + + commonTestHelper.waitWithLatch { latch -> + + val childObserver = object : Observer> { + override fun onChanged(children: List?) { + System.out.println("## TEST | Space A flat children update : ${children?.map { it.name }}") + if (children?.any { it.roomId == bRoomId } == false) { + // B1 has been added live! + latch.countDown() + flatAChildren.removeObserver(this) + } + } + } + + // part from b room + runBlocking { + bRoom!!.leave(null) + } + // The room should have disapear from flat children + GlobalScope.launch(Dispatchers.Main) { flatAChildren.observeForever(childObserver) } + } + } + + data class TestSpaceCreationResult( + val spaceId: String, + val roomIds: List + ) + + private fun createPublicSpace(session: Session, + spaceName: String, + childInfo: List> + /** Name, auto-join, canonical*/ + ): TestSpaceCreationResult { + val spaceId = runBlocking { + session.spaceService().createSpace(spaceName, "Test Topic", null, true) + } + + val syncedSpace = session.spaceService().getSpace(spaceId) + val viaServers = listOf(session.sessionParams.homeServerHost ?: "") + + val roomIds = + childInfo.map { entry -> + runBlocking { + session.createRoom(CreateRoomParams().apply { name = entry.first }) + } + } + + roomIds.forEachIndexed { index, roomId -> + runBlocking { + syncedSpace!!.addChildren(roomId, viaServers, null, childInfo[index].second) + val canonical = childInfo[index].third + if (canonical != null) { + session.spaceService().setSpaceParent(roomId, spaceId, canonical, viaServers) + } + } + } + return TestSpaceCreationResult(spaceId, roomIds) + } + + @Test + fun testRootSpaces() { + val session = commonTestHelper.createAccount("John", SessionTestParams(true)) + + val spaceAInfo = createPublicSpace(session, "SpaceA", listOf( + Triple("A1", true /*auto-join*/, true/*canonical*/), + Triple("A2", true, true) + )) + + val spaceBInfo = createPublicSpace(session, "SpaceB", listOf( + Triple("B1", true /*auto-join*/, true/*canonical*/), + Triple("B2", true, true), + Triple("B3", true, true) + )) + + val spaceCInfo = createPublicSpace(session, "SpaceC", listOf( + Triple("C1", true /*auto-join*/, true/*canonical*/), + Triple("C2", true, true) + )) + + val viaServers = listOf(session.sessionParams.homeServerHost ?: "") + + // add C as subspace of B + runBlocking { + val spaceB = session.spaceService().getSpace(spaceBInfo.spaceId) + spaceB!!.addChildren(spaceCInfo.spaceId, viaServers, null, true) + } + + Thread.sleep(2000) + // + A + // a1, a2 + // + B + // b1, b2, b3 + // + C + // + c1, c2 + + val rootSpaces = session.spaceService().getRootSpaceSummaries() + + assertEquals(2, rootSpaces.size, "Unexpected number of root spaces ${rootSpaces.map { it.name }}") + } +} diff --git a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/query/ActiveSpaceFilter.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/query/ActiveSpaceFilter.kt new file mode 100644 index 0000000000..48619b9394 --- /dev/null +++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/query/ActiveSpaceFilter.kt @@ -0,0 +1,23 @@ +/* + * Copyright (c) 2021 The Matrix.org Foundation C.I.C. + * + * 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 org.matrix.android.sdk.api.query + +sealed class ActiveSpaceFilter { + object None : ActiveSpaceFilter() + data class ActiveSpace(val currentSpaceId: String?) : ActiveSpaceFilter() + data class ExcludeSpace(val spaceId: String) : ActiveSpaceFilter() +} diff --git a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/session/Session.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/session/Session.kt index a15799d862..5f442c33f9 100644 --- a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/session/Session.kt +++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/session/Session.kt @@ -48,6 +48,7 @@ import org.matrix.android.sdk.api.session.search.SearchService import org.matrix.android.sdk.api.session.securestorage.SecureStorageService import org.matrix.android.sdk.api.session.securestorage.SharedSecretStorageService import org.matrix.android.sdk.api.session.signout.SignOutService +import org.matrix.android.sdk.api.session.space.SpaceService import org.matrix.android.sdk.api.session.sync.FilterService import org.matrix.android.sdk.api.session.sync.SyncState import org.matrix.android.sdk.api.session.terms.TermsService @@ -227,6 +228,11 @@ interface Session : */ fun thirdPartyService(): ThirdPartyService + /** + * Returns the space service associated with the session + */ + fun spaceService(): SpaceService + /** * Add a listener to the session. * @param listener the listener to add. diff --git a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/session/events/model/EventType.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/session/events/model/EventType.kt index 905e18b8e8..b4a58e5ee6 100644 --- a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/session/events/model/EventType.kt +++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/session/events/model/EventType.kt @@ -52,6 +52,12 @@ object EventType { const val STATE_ROOM_GUEST_ACCESS = "m.room.guest_access" const val STATE_ROOM_POWER_LEVELS = "m.room.power_levels" + // const val STATE_SPACE_CHILD = "m.space.child" + const val STATE_SPACE_CHILD = "org.matrix.msc1772.space.child" + + // const val STATE_SPACE_PARENT = "m.space.parent" + const val STATE_SPACE_PARENT = "org.matrix.msc1772.space.parent" + /** * Note that this Event has been deprecated, see * - https://matrix.org/docs/spec/client_server/r0.6.1#historical-events @@ -74,6 +80,7 @@ object EventType { const val CALL_NEGOTIATE = "m.call.negotiate" const val CALL_REJECT = "m.call.reject" const val CALL_HANGUP = "m.call.hangup" + // This type is not processed by the client, just sent to the server const val CALL_REPLACES = "m.call.replaces" diff --git a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/session/room/Room.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/session/room/Room.kt index 257c83564e..f3eeb902a8 100644 --- a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/session/room/Room.kt +++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/session/room/Room.kt @@ -34,6 +34,7 @@ import org.matrix.android.sdk.api.session.room.timeline.TimelineService import org.matrix.android.sdk.api.session.room.typing.TypingService import org.matrix.android.sdk.api.session.room.uploads.UploadsService import org.matrix.android.sdk.api.session.search.SearchResult +import org.matrix.android.sdk.api.session.space.Space import org.matrix.android.sdk.api.util.Optional /** @@ -91,4 +92,9 @@ interface Room : beforeLimit: Int, afterLimit: Int, includeProfile: Boolean): SearchResult + + /** + * Use this room as a Space, if the type is correct. + */ + fun asSpace(): Space? } diff --git a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/session/room/RoomService.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/session/room/RoomService.kt index 22045366cb..871c5378a6 100644 --- a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/session/room/RoomService.kt +++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/session/room/RoomService.kt @@ -20,6 +20,7 @@ import androidx.lifecycle.LiveData import androidx.paging.PagedList import org.matrix.android.sdk.api.session.events.model.Event import org.matrix.android.sdk.api.session.room.members.ChangeMembershipState +import org.matrix.android.sdk.api.session.room.model.Membership import org.matrix.android.sdk.api.session.room.model.RoomMemberSummary import org.matrix.android.sdk.api.session.room.model.RoomSummary import org.matrix.android.sdk.api.session.room.model.create.CreateRoomParams @@ -177,13 +178,15 @@ interface RoomService { * TODO Doc */ fun getPagedRoomSummariesLive(queryParams: RoomSummaryQueryParams, - pagedListConfig: PagedList.Config = defaultPagedListConfig): LiveData> + pagedListConfig: PagedList.Config = defaultPagedListConfig, + sortOrder: RoomSortOrder = RoomSortOrder.ACTIVITY): LiveData> /** * TODO Doc */ fun getFilteredPagedRoomSummariesLive(queryParams: RoomSummaryQueryParams, - pagedListConfig: PagedList.Config = defaultPagedListConfig): UpdatableFilterLivePageResult + pagedListConfig: PagedList.Config = defaultPagedListConfig, + sortOrder: RoomSortOrder = RoomSortOrder.ACTIVITY): UpdatableLivePageResult /** * TODO Doc @@ -197,4 +200,12 @@ interface RoomService { .setEnablePlaceholders(false) .setPrefetchDistance(10) .build() + + fun getFlattenRoomSummaryChildrenOf(spaceId: String?, memberships: List = Membership.activeMemberships()) : List + + /** + * Returns all the children of this space, as LiveData + */ + fun getFlattenRoomSummaryChildrenOfLive(spaceId: String?, + memberships: List = Membership.activeMemberships()): LiveData> } diff --git a/vector/src/main/java/im/vector/app/features/grouplist/GroupListAction.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/session/room/RoomSortOrder.kt similarity index 62% rename from vector/src/main/java/im/vector/app/features/grouplist/GroupListAction.kt rename to matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/session/room/RoomSortOrder.kt index 4d974b8ce8..36da242527 100644 --- a/vector/src/main/java/im/vector/app/features/grouplist/GroupListAction.kt +++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/session/room/RoomSortOrder.kt @@ -1,5 +1,5 @@ /* - * Copyright 2019 New Vector Ltd + * Copyright 2021 The Matrix.org Foundation C.I.C. * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -12,14 +12,12 @@ * 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 im.vector.app.features.grouplist +package org.matrix.android.sdk.api.session.room -import im.vector.app.core.platform.VectorViewModelAction -import org.matrix.android.sdk.api.session.group.model.GroupSummary - -sealed class GroupListAction : VectorViewModelAction { - data class SelectGroup(val groupSummary: GroupSummary) : GroupListAction() +enum class RoomSortOrder { + NAME, + ACTIVITY, + NONE } diff --git a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/session/room/RoomSummaryQueryParams.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/session/room/RoomSummaryQueryParams.kt index 7e04ebb5f2..42dbecdaad 100644 --- a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/session/room/RoomSummaryQueryParams.kt +++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/session/room/RoomSummaryQueryParams.kt @@ -16,15 +16,35 @@ package org.matrix.android.sdk.api.session.room +import org.matrix.android.sdk.api.query.ActiveSpaceFilter import org.matrix.android.sdk.api.query.QueryStringValue import org.matrix.android.sdk.api.query.RoomCategoryFilter import org.matrix.android.sdk.api.query.RoomTagQueryFilter import org.matrix.android.sdk.api.session.room.model.Membership +import org.matrix.android.sdk.api.session.room.model.RoomType +import org.matrix.android.sdk.api.session.space.SpaceSummaryQueryParams fun roomSummaryQueryParams(init: (RoomSummaryQueryParams.Builder.() -> Unit) = {}): RoomSummaryQueryParams { return RoomSummaryQueryParams.Builder().apply(init).build() } +fun spaceSummaryQueryParams(init: (RoomSummaryQueryParams.Builder.() -> Unit) = {}): SpaceSummaryQueryParams { + return RoomSummaryQueryParams.Builder() + .apply(init) + .apply { + includeType = listOf(RoomType.SPACE) + excludeType = null + roomCategoryFilter = RoomCategoryFilter.ONLY_ROOMS + } + .build() +} + +enum class RoomCategoryFilter { + ONLY_DM, + ONLY_ROOMS, + ALL +} + /** * This class can be used to filter room summaries to use with: * [org.matrix.android.sdk.api.session.room.Room] and [org.matrix.android.sdk.api.session.room.RoomService] @@ -35,7 +55,11 @@ data class RoomSummaryQueryParams( val canonicalAlias: QueryStringValue, val memberships: List, val roomCategoryFilter: RoomCategoryFilter?, - val roomTagQueryFilter: RoomTagQueryFilter? + val roomTagQueryFilter: RoomTagQueryFilter?, + val excludeType: List?, + val includeType: List?, + val activeSpaceId: ActiveSpaceFilter?, + var activeGroupId: String? = null ) { class Builder { @@ -46,6 +70,10 @@ data class RoomSummaryQueryParams( var memberships: List = Membership.all() var roomCategoryFilter: RoomCategoryFilter? = RoomCategoryFilter.ALL var roomTagQueryFilter: RoomTagQueryFilter? = null + var excludeType: List? = listOf(RoomType.SPACE) + var includeType: List? = null + var activeSpaceId: ActiveSpaceFilter = ActiveSpaceFilter.None + var activeGroupId: String? = null fun build() = RoomSummaryQueryParams( roomId = roomId, @@ -53,7 +81,11 @@ data class RoomSummaryQueryParams( canonicalAlias = canonicalAlias, memberships = memberships, roomCategoryFilter = roomCategoryFilter, - roomTagQueryFilter = roomTagQueryFilter + roomTagQueryFilter = roomTagQueryFilter, + excludeType = excludeType, + includeType = includeType, + activeSpaceId = activeSpaceId, + activeGroupId = activeGroupId ) } } diff --git a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/session/room/UpdatableFilterLivePageResult.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/session/room/UpdatableLivePageResult.kt similarity index 88% rename from matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/session/room/UpdatableFilterLivePageResult.kt rename to matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/session/room/UpdatableLivePageResult.kt index 71b3c665e7..3bcca0c241 100644 --- a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/session/room/UpdatableFilterLivePageResult.kt +++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/session/room/UpdatableLivePageResult.kt @@ -20,8 +20,8 @@ import androidx.lifecycle.LiveData import androidx.paging.PagedList import org.matrix.android.sdk.api.session.room.model.RoomSummary -interface UpdatableFilterLivePageResult { +interface UpdatableLivePageResult { val livePagedList: LiveData> - fun updateQuery(queryParams: RoomSummaryQueryParams) + fun updateQuery(builder: (RoomSummaryQueryParams) -> RoomSummaryQueryParams) } diff --git a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/session/room/alias/RoomAliasError.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/session/room/alias/RoomAliasError.kt index d2cb7c58a9..1102eda11c 100644 --- a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/session/room/alias/RoomAliasError.kt +++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/session/room/alias/RoomAliasError.kt @@ -17,7 +17,7 @@ package org.matrix.android.sdk.api.session.room.alias sealed class RoomAliasError : Throwable() { - object AliasEmpty : RoomAliasError() + object AliasIsBlank : RoomAliasError() object AliasNotAvailable : RoomAliasError() object AliasInvalid : RoomAliasError() } diff --git a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/session/room/failure/CreateRoomFailure.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/session/room/failure/CreateRoomFailure.kt index 208cdd4556..deab0ca3e7 100644 --- a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/session/room/failure/CreateRoomFailure.kt +++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/session/room/failure/CreateRoomFailure.kt @@ -21,7 +21,7 @@ import org.matrix.android.sdk.api.failure.MatrixError import org.matrix.android.sdk.api.session.room.alias.RoomAliasError sealed class CreateRoomFailure : Failure.FeatureFailure() { - object CreatedWithTimeout : CreateRoomFailure() + data class CreatedWithTimeout(val roomID: String) : CreateRoomFailure() data class CreatedWithFederationFailure(val matrixError: MatrixError) : CreateRoomFailure() data class AliasError(val aliasError: RoomAliasError) : CreateRoomFailure() } diff --git a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/session/room/model/PowerLevelsContent.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/session/room/model/PowerLevelsContent.kt index e778f5740d..5c46db7166 100644 --- a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/session/room/model/PowerLevelsContent.kt +++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/session/room/model/PowerLevelsContent.kt @@ -28,43 +28,43 @@ data class PowerLevelsContent( /** * The level required to ban a user. Defaults to 50 if unspecified. */ - @Json(name = "ban") val ban: Int = Role.Moderator.value, + @Json(name = "ban") val ban: Int? = null, /** * The level required to kick a user. Defaults to 50 if unspecified. */ - @Json(name = "kick") val kick: Int = Role.Moderator.value, + @Json(name = "kick") val kick: Int? = null, /** * The level required to invite a user. Defaults to 50 if unspecified. */ - @Json(name = "invite") val invite: Int = Role.Moderator.value, + @Json(name = "invite") val invite: Int? = null, /** * The level required to redact an event. Defaults to 50 if unspecified. */ - @Json(name = "redact") val redact: Int = Role.Moderator.value, + @Json(name = "redact") val redact: Int? = null, /** * The default level required to send message events. Can be overridden by the events key. Defaults to 0 if unspecified. */ - @Json(name = "events_default") val eventsDefault: Int = Role.Default.value, + @Json(name = "events_default") val eventsDefault: Int? = null, /** * The level required to send specific event types. This is a mapping from event type to power level required. */ - @Json(name = "events") val events: Map = emptyMap(), + @Json(name = "events") val events: Map? = null, /** * The default power level for every user in the room, unless their user_id is mentioned in the users key. Defaults to 0 if unspecified. */ - @Json(name = "users_default") val usersDefault: Int = Role.Default.value, + @Json(name = "users_default") val usersDefault: Int? = null, /** * The power levels for specific users. This is a mapping from user_id to power level for that user. */ - @Json(name = "users") val users: Map = emptyMap(), + @Json(name = "users") val users: Map? = null, /** * The default level required to send state events. Can be overridden by the events key. Defaults to 50 if unspecified. */ - @Json(name = "state_default") val stateDefault: Int = Role.Moderator.value, + @Json(name = "state_default") val stateDefault: Int? = null, /** * The power level requirements for specific notification types. This is a mapping from key to power level for that notifications key. */ - @Json(name = "notifications") val notifications: Map = emptyMap() + @Json(name = "notifications") val notifications: Map? = null ) { /** * Return a copy of this content with a new power level for the specified user @@ -74,7 +74,7 @@ data class PowerLevelsContent( */ fun setUserPowerLevel(userId: String, powerLevel: Int?): PowerLevelsContent { return copy( - users = users.toMutableMap().apply { + users = users.orEmpty().toMutableMap().apply { if (powerLevel == null || powerLevel == usersDefault) { remove(userId) } else { @@ -91,7 +91,7 @@ data class PowerLevelsContent( * @return the level, default to Moderator if the key is not found */ fun notificationLevel(key: String): Int { - return when (val value = notifications[key]) { + return when (val value = notifications.orEmpty()[key]) { // the first implementation was a string value is String -> value.toInt() is Double -> value.toInt() @@ -107,3 +107,12 @@ data class PowerLevelsContent( const val NOTIFICATIONS_ROOM_KEY = "room" } } + +// Fallback to default value, defined in the Matrix specification +fun PowerLevelsContent.banOrDefault() = ban ?: Role.Moderator.value +fun PowerLevelsContent.kickOrDefault() = kick ?: Role.Moderator.value +fun PowerLevelsContent.inviteOrDefault() = invite ?: Role.Moderator.value +fun PowerLevelsContent.redactOrDefault() = redact ?: Role.Moderator.value +fun PowerLevelsContent.eventsDefaultOrDefault() = eventsDefault ?: Role.Default.value +fun PowerLevelsContent.usersDefaultOrDefault() = usersDefault ?: Role.Default.value +fun PowerLevelsContent.stateDefaultOrDefault() = stateDefault ?: Role.Moderator.value diff --git a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/session/room/model/RoomGuestAccessContent.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/session/room/model/RoomGuestAccessContent.kt index 0760c6f1b4..020e7ed39e 100644 --- a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/session/room/model/RoomGuestAccessContent.kt +++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/session/room/model/RoomGuestAccessContent.kt @@ -40,7 +40,7 @@ data class RoomGuestAccessContent( } @JsonClass(generateAdapter = false) -enum class GuestAccess { - @Json(name = "can_join") CanJoin, - @Json(name = "forbidden") Forbidden +enum class GuestAccess(val value: String) { + @Json(name = "can_join") CanJoin("can_join"), + @Json(name = "forbidden") Forbidden("forbidden") } diff --git a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/session/room/model/RoomHistoryVisibility.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/session/room/model/RoomHistoryVisibility.kt index 06069f2646..e980be93ed 100644 --- a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/session/room/model/RoomHistoryVisibility.kt +++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/session/room/model/RoomHistoryVisibility.kt @@ -16,35 +16,31 @@ package org.matrix.android.sdk.api.session.room.model -import com.squareup.moshi.Json -import com.squareup.moshi.JsonClass - /** * Ref: https://matrix.org/docs/spec/client_server/latest#room-history-visibility */ -@JsonClass(generateAdapter = false) enum class RoomHistoryVisibility { /** * All events while this is the m.room.history_visibility value may be shared by any * participating homeserver with anyone, regardless of whether they have ever joined the room. */ - @Json(name = "world_readable") WORLD_READABLE, + WORLD_READABLE, /** * Previous events are always accessible to newly joined members. All events in the * room are accessible, even those sent when the member was not a part of the room. */ - @Json(name = "shared") SHARED, + SHARED, /** * Events are accessible to newly joined members from the point they were invited onwards. * Events stop being accessible when the member's state changes to something other than invite or join. */ - @Json(name = "invited") INVITED, + INVITED, /** * Events are accessible to newly joined members from the point they joined the room onwards. * Events stop being accessible when the member's state changes to something other than join. */ - @Json(name = "joined") JOINED + JOINED } diff --git a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/session/room/model/RoomJoinRules.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/session/room/model/RoomJoinRules.kt index f3e8d357f3..a86301a276 100644 --- a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/session/room/model/RoomJoinRules.kt +++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/session/room/model/RoomJoinRules.kt @@ -24,9 +24,10 @@ import com.squareup.moshi.JsonClass * Enum for [RoomJoinRulesContent] : https://matrix.org/docs/spec/client_server/r0.4.0#m-room-join-rules */ @JsonClass(generateAdapter = false) -enum class RoomJoinRules { - @Json(name = "public") PUBLIC, - @Json(name = "invite") INVITE, - @Json(name = "knock") KNOCK, - @Json(name = "private") PRIVATE +enum class RoomJoinRules(val value: String) { + @Json(name = "public") PUBLIC("public"), + @Json(name = "invite") INVITE("invite"), + @Json(name = "knock") KNOCK("knock"), + @Json(name = "private") PRIVATE("private"), + @Json(name = "restricted") RESTRICTED("restricted") } diff --git a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/session/room/model/RoomJoinRulesAllowEntry.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/session/room/model/RoomJoinRulesAllowEntry.kt new file mode 100644 index 0000000000..7b87bc34d2 --- /dev/null +++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/session/room/model/RoomJoinRulesAllowEntry.kt @@ -0,0 +1,33 @@ +/* + * Copyright 2021 The Matrix.org Foundation C.I.C. + * + * 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 org.matrix.android.sdk.api.session.room.model + +import com.squareup.moshi.Json +import com.squareup.moshi.JsonClass + +@JsonClass(generateAdapter = true) +data class RoomJoinRulesAllowEntry( + /** + * space: The room ID of the space to check the membership of. + */ + @Json(name = "space") val spaceID: String, + /** + * via: A list of servers which may be used to peek for membership of the space. + */ + @Json(name = "via") val via: List +) diff --git a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/session/room/model/RoomJoinRulesContent.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/session/room/model/RoomJoinRulesContent.kt index 8082486b22..33f402cad3 100644 --- a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/session/room/model/RoomJoinRulesContent.kt +++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/session/room/model/RoomJoinRulesContent.kt @@ -1,5 +1,6 @@ /* * Copyright 2020 The Matrix.org Foundation C.I.C. + * Copyright 2021 The Matrix.org Foundation C.I.C. * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -26,14 +27,19 @@ import timber.log.Timber */ @JsonClass(generateAdapter = true) data class RoomJoinRulesContent( - @Json(name = "join_rule") val _joinRules: String? = null + @Json(name = "join_rule") val _joinRules: String? = null, + /** + * If the allow key is an empty list (or not a list at all), then the room reverts to standard public join rules + */ + @Json(name = "allow") val allowList: List? = null ) { val joinRules: RoomJoinRules? = when (_joinRules) { - "public" -> RoomJoinRules.PUBLIC - "invite" -> RoomJoinRules.INVITE - "knock" -> RoomJoinRules.KNOCK + "public" -> RoomJoinRules.PUBLIC + "invite" -> RoomJoinRules.INVITE + "knock" -> RoomJoinRules.KNOCK "private" -> RoomJoinRules.PRIVATE - else -> { + "restricted" -> RoomJoinRules.RESTRICTED + else -> { Timber.w("Invalid value for RoomJoinRules: `$_joinRules`") null } diff --git a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/session/room/model/RoomSummary.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/session/room/model/RoomSummary.kt index 8a2aecd76d..d324cff246 100644 --- a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/session/room/model/RoomSummary.kt +++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/session/room/model/RoomSummary.kt @@ -55,7 +55,11 @@ data class RoomSummary constructor( val inviterId: String? = null, val breadcrumbsIndex: Int = NOT_IN_BREADCRUMBS, val roomEncryptionTrustLevel: RoomEncryptionTrustLevel? = null, - val hasFailedSending: Boolean = false + val hasFailedSending: Boolean = false, + val roomType: String? = null, + val spaceParents: List? = null, + val spaceChildren: List? = null, + val flattenParentIds: List = emptyList() ) { val isVersioned: Boolean diff --git a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/session/room/model/RoomThirdPartyInviteContent.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/session/room/model/RoomThirdPartyInviteContent.kt index 56503e3e35..a8a2cfb68b 100644 --- a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/session/room/model/RoomThirdPartyInviteContent.kt +++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/session/room/model/RoomThirdPartyInviteContent.kt @@ -47,7 +47,7 @@ data class RoomThirdPartyInviteContent( /** * Keys with which the token may be signed. */ - @Json(name = "public_keys") val publicKeys: List? = emptyList() + @Json(name = "public_keys") val publicKeys: List? ) @JsonClass(generateAdapter = true) diff --git a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/session/room/model/RoomType.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/session/room/model/RoomType.kt new file mode 100644 index 0000000000..b4932494f2 --- /dev/null +++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/session/room/model/RoomType.kt @@ -0,0 +1,23 @@ +/* + * Copyright 2020 The Matrix.org Foundation C.I.C. + * + * 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 org.matrix.android.sdk.api.session.room.model + +object RoomType { + + const val SPACE = "org.matrix.msc1772.space" // "m.space" +// const val MESSAGING = "org.matrix.msc1840.messaging" +} diff --git a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/session/room/model/SpaceChildInfo.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/session/room/model/SpaceChildInfo.kt new file mode 100644 index 0000000000..fd5fbf7bb0 --- /dev/null +++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/session/room/model/SpaceChildInfo.kt @@ -0,0 +1,33 @@ +/* + * Copyright 2020 The Matrix.org Foundation C.I.C. + * + * 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 org.matrix.android.sdk.api.session.room.model + +data class SpaceChildInfo( + val childRoomId: String, + // We might not know this child at all, + // i.e we just know it exists but no info on type/name/etc.. + val isKnown: Boolean, + val roomType: String?, + val name: String?, + val topic: String?, + val avatarUrl: String?, + val order: String?, + val activeMemberCount: Int?, + val autoJoin: Boolean, + val viaServers: List, + val parentRoomId: String? +) diff --git a/vector/src/main/java/im/vector/app/features/grouplist/GroupListViewState.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/session/room/model/SpaceParentInfo.kt similarity index 58% rename from vector/src/main/java/im/vector/app/features/grouplist/GroupListViewState.kt rename to matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/session/room/model/SpaceParentInfo.kt index 4abcff2f67..5ed81b0646 100644 --- a/vector/src/main/java/im/vector/app/features/grouplist/GroupListViewState.kt +++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/session/room/model/SpaceParentInfo.kt @@ -1,5 +1,5 @@ /* - * Copyright 2019 New Vector Ltd + * Copyright 2020 The Matrix.org Foundation C.I.C. * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -14,14 +14,11 @@ * limitations under the License. */ -package im.vector.app.features.grouplist +package org.matrix.android.sdk.api.session.room.model -import com.airbnb.mvrx.Async -import com.airbnb.mvrx.MvRxState -import com.airbnb.mvrx.Uninitialized -import org.matrix.android.sdk.api.session.group.model.GroupSummary - -data class GroupListViewState( - val asyncGroups: Async> = Uninitialized, - val selectedGroup: GroupSummary? = null -) : MvRxState +data class SpaceParentInfo( + val parentId: String?, + val roomSummary: RoomSummary?, + val canonical: Boolean?, + val viaServers: List +) diff --git a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/session/room/model/create/CreateRoomParams.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/session/room/model/create/CreateRoomParams.kt index 80e3741a0c..cd729832eb 100644 --- a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/session/room/model/create/CreateRoomParams.kt +++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/session/room/model/create/CreateRoomParams.kt @@ -18,13 +18,15 @@ package org.matrix.android.sdk.api.session.room.model.create import android.net.Uri import org.matrix.android.sdk.api.session.identity.ThreePid +import org.matrix.android.sdk.api.session.room.model.GuestAccess import org.matrix.android.sdk.api.session.room.model.PowerLevelsContent import org.matrix.android.sdk.api.session.room.model.RoomDirectoryVisibility import org.matrix.android.sdk.api.session.room.model.RoomHistoryVisibility +import org.matrix.android.sdk.api.session.room.model.RoomJoinRulesAllowEntry import org.matrix.android.sdk.internal.crypto.MXCRYPTO_ALGORITHM_MEGOLM // TODO Give a way to include other initial states -class CreateRoomParams { +open class CreateRoomParams { /** * A public visibility indicates that the room will be shown in the published room list. * A private visibility will hide the room from the published room list. @@ -68,6 +70,11 @@ class CreateRoomParams { */ val invite3pids = mutableListOf() + /** + * Initial Guest Access + */ + var guestAccess: GuestAccess? = null + /** * If set to true, when the room will be created, if cross-signing is enabled and we can get keys for every invited users, * the encryption will be enabled on the created room @@ -111,6 +118,17 @@ class CreateRoomParams { } } + var roomType: String? = null // RoomType.MESSAGING + set(value) { + field = value + if (value != null) { + creationContent[CREATION_CONTENT_KEY_ROOM_TYPE] = value + } else { + // This is the default value, we remove the field + creationContent.remove(CREATION_CONTENT_KEY_ROOM_TYPE) + } + } + /** * The power level content to override in the default power level event */ @@ -136,7 +154,12 @@ class CreateRoomParams { algorithm = MXCRYPTO_ALGORITHM_MEGOLM } + var roomVersion: String? = null + + var joinRuleRestricted: List? = null + companion object { private const val CREATION_CONTENT_KEY_M_FEDERATE = "m.federate" + private const val CREATION_CONTENT_KEY_ROOM_TYPE = "org.matrix.msc1772.type" } } diff --git a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/session/room/model/create/RoomCreateContent.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/session/room/model/create/RoomCreateContent.kt index 0b595b1b2b..f9d40d5652 100644 --- a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/session/room/model/create/RoomCreateContent.kt +++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/session/room/model/create/RoomCreateContent.kt @@ -26,5 +26,7 @@ import com.squareup.moshi.JsonClass data class RoomCreateContent( @Json(name = "creator") val creator: String? = null, @Json(name = "room_version") val roomVersion: String? = null, - @Json(name = "predecessor") val predecessor: Predecessor? = null + @Json(name = "predecessor") val predecessor: Predecessor? = null, + // Defines the room type, see #RoomType (user extensible) + @Json(name = "org.matrix.msc1772.type") val type: String? = null ) diff --git a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/session/room/peeking/PeekResult.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/session/room/peeking/PeekResult.kt index db70dadef3..888950dc12 100644 --- a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/session/room/peeking/PeekResult.kt +++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/session/room/peeking/PeekResult.kt @@ -16,6 +16,8 @@ package org.matrix.android.sdk.api.session.room.peeking +import org.matrix.android.sdk.api.util.MatrixItem + sealed class PeekResult { data class Success( val roomId: String, @@ -24,7 +26,9 @@ sealed class PeekResult { val topic: String?, val avatarUrl: String?, val numJoinedMembers: Int?, - val viaServers: List + val roomType: String?, + val viaServers: List, + val someMembers: List? ) : PeekResult() data class PeekingNotAllowed( @@ -34,4 +38,6 @@ sealed class PeekResult { ) : PeekResult() object UnknownAlias : PeekResult() + + fun isSuccess() = this is Success } diff --git a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/session/room/powerlevels/PowerLevelsHelper.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/session/room/powerlevels/PowerLevelsHelper.kt index 4f1253c6df..99139723a8 100644 --- a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/session/room/powerlevels/PowerLevelsHelper.kt +++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/session/room/powerlevels/PowerLevelsHelper.kt @@ -18,6 +18,13 @@ package org.matrix.android.sdk.api.session.room.powerlevels import org.matrix.android.sdk.api.session.room.model.PowerLevelsContent +import org.matrix.android.sdk.api.session.room.model.banOrDefault +import org.matrix.android.sdk.api.session.room.model.eventsDefaultOrDefault +import org.matrix.android.sdk.api.session.room.model.inviteOrDefault +import org.matrix.android.sdk.api.session.room.model.kickOrDefault +import org.matrix.android.sdk.api.session.room.model.redactOrDefault +import org.matrix.android.sdk.api.session.room.model.stateDefaultOrDefault +import org.matrix.android.sdk.api.session.room.model.usersDefaultOrDefault /** * This class is an helper around PowerLevelsContent. @@ -31,9 +38,9 @@ class PowerLevelsHelper(private val powerLevelsContent: PowerLevelsContent) { * @return the power level */ fun getUserPowerLevelValue(userId: String): Int { - return powerLevelsContent.users.getOrElse(userId) { - powerLevelsContent.usersDefault - } + return powerLevelsContent.users + ?.get(userId) + ?: powerLevelsContent.usersDefaultOrDefault() } /** @@ -45,7 +52,7 @@ class PowerLevelsHelper(private val powerLevelsContent: PowerLevelsContent) { fun getUserRole(userId: String): Role { val value = getUserPowerLevelValue(userId) // I think we should use powerLevelsContent.usersDefault, but Ganfra told me that it was like that on riot-Web - return Role.fromValue(value, powerLevelsContent.eventsDefault) + return Role.fromValue(value, powerLevelsContent.eventsDefaultOrDefault()) } /** @@ -59,11 +66,11 @@ class PowerLevelsHelper(private val powerLevelsContent: PowerLevelsContent) { fun isUserAllowedToSend(userId: String, isState: Boolean, eventType: String?): Boolean { return if (userId.isNotEmpty()) { val powerLevel = getUserPowerLevelValue(userId) - val minimumPowerLevel = powerLevelsContent.events[eventType] + val minimumPowerLevel = powerLevelsContent.events?.get(eventType) ?: if (isState) { - powerLevelsContent.stateDefault + powerLevelsContent.stateDefaultOrDefault() } else { - powerLevelsContent.eventsDefault + powerLevelsContent.eventsDefaultOrDefault() } powerLevel >= minimumPowerLevel } else false @@ -76,7 +83,7 @@ class PowerLevelsHelper(private val powerLevelsContent: PowerLevelsContent) { */ fun isUserAbleToInvite(userId: String): Boolean { val powerLevel = getUserPowerLevelValue(userId) - return powerLevel >= powerLevelsContent.invite + return powerLevel >= powerLevelsContent.inviteOrDefault() } /** @@ -86,7 +93,7 @@ class PowerLevelsHelper(private val powerLevelsContent: PowerLevelsContent) { */ fun isUserAbleToBan(userId: String): Boolean { val powerLevel = getUserPowerLevelValue(userId) - return powerLevel >= powerLevelsContent.ban + return powerLevel >= powerLevelsContent.banOrDefault() } /** @@ -96,7 +103,7 @@ class PowerLevelsHelper(private val powerLevelsContent: PowerLevelsContent) { */ fun isUserAbleToKick(userId: String): Boolean { val powerLevel = getUserPowerLevelValue(userId) - return powerLevel >= powerLevelsContent.kick + return powerLevel >= powerLevelsContent.kickOrDefault() } /** @@ -106,6 +113,6 @@ class PowerLevelsHelper(private val powerLevelsContent: PowerLevelsContent) { */ fun isUserAbleToRedact(userId: String): Boolean { val powerLevel = getUserPowerLevelValue(userId) - return powerLevel >= powerLevelsContent.redact + return powerLevel >= powerLevelsContent.redactOrDefault() } } diff --git a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/session/room/summary/RoomAggregateNotificationCount.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/session/room/summary/RoomAggregateNotificationCount.kt index 066178b1ec..b3440059e8 100644 --- a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/session/room/summary/RoomAggregateNotificationCount.kt +++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/session/room/summary/RoomAggregateNotificationCount.kt @@ -20,6 +20,6 @@ data class RoomAggregateNotificationCount( val notificationCount: Int, val highlightCount: Int ) { - val totalCount = notificationCount + highlightCount + val totalCount = notificationCount val isHighlight = highlightCount > 0 } diff --git a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/session/space/CreateSpaceParams.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/session/space/CreateSpaceParams.kt new file mode 100644 index 0000000000..42e6584838 --- /dev/null +++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/session/space/CreateSpaceParams.kt @@ -0,0 +1,35 @@ +/* + * Copyright 2020 The Matrix.org Foundation C.I.C. + * + * 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 org.matrix.android.sdk.api.session.space + +import org.matrix.android.sdk.api.session.room.model.PowerLevelsContent +import org.matrix.android.sdk.api.session.room.model.RoomType +import org.matrix.android.sdk.api.session.room.model.create.CreateRoomParams + +class CreateSpaceParams : CreateRoomParams() { + + init { + // Space-rooms are distinguished from regular messaging rooms by the m.room.type of m.space + roomType = RoomType.SPACE + + // Space-rooms should be created with a power level for events_default of 100, + // to prevent the rooms accidentally/maliciously clogging up with messages from random members of the space. + powerLevelContentOverride = PowerLevelsContent( + eventsDefault = 100 + ) + } +} diff --git a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/session/space/JoinSpaceResult.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/session/space/JoinSpaceResult.kt new file mode 100644 index 0000000000..e8c69977c6 --- /dev/null +++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/session/space/JoinSpaceResult.kt @@ -0,0 +1,27 @@ +/* + * Copyright 2021 The Matrix.org Foundation C.I.C. + * + * 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 org.matrix.android.sdk.api.session.space + +sealed class JoinSpaceResult { + object Success : JoinSpaceResult() + data class Fail(val error: Throwable) : JoinSpaceResult() + + /** Success fully joined the space, but failed to join all or some of it's rooms */ + data class PartialSuccess(val failedRooms: Map) : JoinSpaceResult() + + fun isSuccess() = this is Success || this is PartialSuccess +} diff --git a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/session/space/Space.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/session/space/Space.kt new file mode 100644 index 0000000000..9dba4f90af --- /dev/null +++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/session/space/Space.kt @@ -0,0 +1,50 @@ +/* + * Copyright 2020 The Matrix.org Foundation C.I.C. + * + * 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 org.matrix.android.sdk.api.session.space + +import org.matrix.android.sdk.api.session.room.Room +import org.matrix.android.sdk.api.session.room.model.RoomSummary + +interface Space { + + fun asRoom(): Room + + val spaceId: String + + suspend fun leave(reason: String? = null) + + /** + * A current snapshot of [RoomSummary] associated with the space + */ + fun spaceSummary(): RoomSummary? + + suspend fun addChildren(roomId: String, + viaServers: List, + order: String?, + autoJoin: Boolean = false, + suggested: Boolean? = false) + + suspend fun removeChildren(roomId: String) + + @Throws + suspend fun setChildrenOrder(roomId: String, order: String?) + + @Throws + suspend fun setChildrenAutoJoin(roomId: String, autoJoin: Boolean) + +// fun getChildren() : List +} diff --git a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/session/space/SpaceService.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/session/space/SpaceService.kt new file mode 100644 index 0000000000..fedf38fe06 --- /dev/null +++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/session/space/SpaceService.kt @@ -0,0 +1,87 @@ +/* + * Copyright 2020 The Matrix.org Foundation C.I.C. + * + * 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 org.matrix.android.sdk.api.session.space + +import android.net.Uri +import androidx.lifecycle.LiveData +import org.matrix.android.sdk.api.session.room.RoomSummaryQueryParams +import org.matrix.android.sdk.api.session.room.model.RoomSummary +import org.matrix.android.sdk.api.session.room.model.SpaceChildInfo +import org.matrix.android.sdk.internal.session.space.peeking.SpacePeekResult + +typealias SpaceSummaryQueryParams = RoomSummaryQueryParams + +interface SpaceService { + + /** + * Create a space asynchronously + * @return the spaceId of the created space + */ + suspend fun createSpace(params: CreateSpaceParams): String + + /** + * Just a shortcut for space creation for ease of use + */ + suspend fun createSpace(name: String, topic: String?, avatarUri: Uri?, isPublic: Boolean): String + + /** + * Get a space from a roomId + * @param spaceId the roomId to look for. + * @return a space with spaceId or null if room type is not space + */ + fun getSpace(spaceId: String): Space? + + /** + * Try to resolve (peek) rooms and subspace in this space. + * Use this call get preview of children of this space, particularly useful to get a + * preview of rooms that you did not join yet. + */ + suspend fun peekSpace(spaceId: String): SpacePeekResult + + /** + * Get's information of a space by querying the server + */ + suspend fun querySpaceChildren(spaceId: String, + suggestedOnly: Boolean? = null, + autoJoinedOnly: Boolean? = null): Pair> + + /** + * Get a live list of space summaries. This list is refreshed as soon as the data changes. + * @return the [LiveData] of List[SpaceSummary] + */ + fun getSpaceSummariesLive(queryParams: SpaceSummaryQueryParams): LiveData> + + fun getSpaceSummaries(spaceSummaryQueryParams: SpaceSummaryQueryParams): List + + suspend fun joinSpace(spaceIdOrAlias: String, + reason: String? = null, + viaServers: List = emptyList()): JoinSpaceResult + + suspend fun rejectInvite(spaceId: String, reason: String?) + +// fun getSpaceParentsOfRoom(roomId: String) : List + + /** + * Let this room declare that it has a parent. + * @param canonical true if it should be the main parent of this room + * In practice, well behaved rooms should only have one canonical parent, but given this is not enforced: + * if multiple are present the client should select the one with the lowest room ID, as determined via a lexicographic utf-8 ordering. + */ + suspend fun setSpaceParent(childRoomId: String, parentSpaceId: String, canonical: Boolean, viaServers: List) + + fun getRootSpaceSummaries(): List +} diff --git a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/session/space/model/SpaceChildContent.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/session/space/model/SpaceChildContent.kt new file mode 100644 index 0000000000..0c33cfa1e6 --- /dev/null +++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/session/space/model/SpaceChildContent.kt @@ -0,0 +1,70 @@ +/* + * Copyright 2020 The Matrix.org Foundation C.I.C. + * + * 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 org.matrix.android.sdk.api.session.space.model + +import com.squareup.moshi.Json +import com.squareup.moshi.JsonClass + +/** + * "content": { + * "via": ["example.com"], + * "order": "abcd", + * "default": true + * } + */ +@JsonClass(generateAdapter = true) +data class SpaceChildContent( + /** + * Key which gives a list of candidate servers that can be used to join the room + * Children where via is not present are ignored. + */ + @Json(name = "via") val via: List? = null, + /** + * The order key is a string which is used to provide a default ordering of siblings in the room list. + * (Rooms are sorted based on a lexicographic ordering of order values; rooms with no order come last. + * orders which are not strings, or do not consist solely of ascii characters in the range \x20 (space) to \x7F (~), + * or consist of more than 50 characters, are forbidden and should be ignored if received.) + */ + @Json(name = "order") val order: String? = null, + /** + * The auto_join flag on a child listing allows a space admin to list the sub-spaces and rooms in that space which should + * be automatically joined by members of that space. + * (This is not a force-join, which are descoped for a future MSC; the user can subsequently part these room if they desire.) + */ + @Json(name = "auto_join") val autoJoin: Boolean? = false, + + /** + * If `suggested` is set to `true`, that indicates that the child should be advertised to + * members of the space by the client. This could be done by showing them eagerly + * in the room list. This is should be ignored if `auto_join` is set to `true`. + */ + @Json(name = "suggested") val suggested: Boolean? = false +) { + /** + * Orders which are not strings, or do not consist solely of ascii characters in the range \x20 (space) to \x7F (~), + * or consist of more than 50 characters, are forbidden and should be ignored if received.) + */ + fun validOrder(): String? { + return order + ?.takeIf { it.length <= 50 } + ?.takeIf { ORDER_VALID_CHAR_REGEX.matches(it) } + } + + companion object { + private val ORDER_VALID_CHAR_REGEX = "[ -~]+".toRegex() + } +} diff --git a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/session/space/model/SpaceParentContent.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/session/space/model/SpaceParentContent.kt new file mode 100644 index 0000000000..871a494914 --- /dev/null +++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/session/space/model/SpaceParentContent.kt @@ -0,0 +1,48 @@ +/* + * Copyright 2020 The Matrix.org Foundation C.I.C. + * + * 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 org.matrix.android.sdk.api.session.space.model + +import com.squareup.moshi.Json +import com.squareup.moshi.JsonClass + +/** + * Rooms can claim parents via the m.space.parent state event. + * { + * "type": "m.space.parent", + * "state_key": "!space:example.com", + * "content": { + * "via": ["example.com"], + * "canonical": true, + * } + * } + */ +@JsonClass(generateAdapter = true) +data class SpaceParentContent( + /** + * Key which gives a list of candidate servers that can be used to join the parent. + * Parents where via is not present are ignored. + */ + @Json(name = "via") val via: List? = null, + /** + * Canonical determines whether this is the main parent for the space. + * When a user joins a room with a canonical parent, clients may switch to view the room + * in the context of that space, peeking into it in order to find other rooms and group them together. + * In practice, well behaved rooms should only have one canonical parent, but given this is not enforced: + * if multiple are present the client should select the one with the lowest room ID, as determined via a lexicographic utf-8 ordering. + */ + @Json(name = "canonical") val canonical: Boolean? = false +) diff --git a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/util/MatrixItem.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/util/MatrixItem.kt index db229a6453..7b2fae86ef 100644 --- a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/util/MatrixItem.kt +++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/util/MatrixItem.kt @@ -20,6 +20,7 @@ import org.matrix.android.sdk.BuildConfig import org.matrix.android.sdk.api.session.group.model.GroupSummary import org.matrix.android.sdk.api.session.room.model.RoomMemberSummary import org.matrix.android.sdk.api.session.room.model.RoomSummary +import org.matrix.android.sdk.api.session.room.model.SpaceChildInfo import org.matrix.android.sdk.api.session.room.model.roomdirectory.PublicRoom import org.matrix.android.sdk.api.session.room.sender.SenderInfo import org.matrix.android.sdk.api.session.user.model.User @@ -157,3 +158,5 @@ fun PublicRoom.toMatrixItem() = MatrixItem.RoomItem(roomId, name ?: getPrimaryAl fun RoomMemberSummary.toMatrixItem() = MatrixItem.UserItem(userId, displayName, avatarUrl) fun SenderInfo.toMatrixItem() = MatrixItem.UserItem(userId, disambiguatedDisplayName, avatarUrl) + +fun SpaceChildInfo.toMatrixItem() = MatrixItem.RoomItem(childRoomId, name, avatarUrl) diff --git a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/database/RealmSessionStoreMigration.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/database/RealmSessionStoreMigration.kt index 1daae906f2..ac72592a1e 100644 --- a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/database/RealmSessionStoreMigration.kt +++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/database/RealmSessionStoreMigration.kt @@ -19,7 +19,10 @@ package org.matrix.android.sdk.internal.database import io.realm.DynamicRealm import io.realm.FieldAttribute import io.realm.RealmMigration +import org.matrix.android.sdk.api.session.events.model.EventType +import org.matrix.android.sdk.api.session.room.model.create.RoomCreateContent import org.matrix.android.sdk.api.session.room.model.tag.RoomTag +import org.matrix.android.sdk.internal.database.model.CurrentStateEventEntityFields import org.matrix.android.sdk.internal.database.model.EditAggregatedSummaryEntityFields import org.matrix.android.sdk.internal.database.model.EditionOfEventFields import org.matrix.android.sdk.internal.database.model.EventEntityFields @@ -31,13 +34,16 @@ import org.matrix.android.sdk.internal.database.model.RoomMembersLoadStatusType import org.matrix.android.sdk.internal.database.model.RoomSummaryEntityFields import org.matrix.android.sdk.internal.database.model.RoomTagEntityFields import org.matrix.android.sdk.internal.database.model.TimelineEventEntityFields +import org.matrix.android.sdk.internal.database.model.SpaceChildSummaryEntityFields +import org.matrix.android.sdk.internal.database.model.SpaceParentSummaryEntityFields +import org.matrix.android.sdk.internal.di.MoshiProvider import timber.log.Timber import javax.inject.Inject class RealmSessionStoreMigration @Inject constructor() : RealmMigration { companion object { - const val SESSION_STORE_SCHEMA_VERSION = 9L + const val SESSION_STORE_SCHEMA_VERSION = 10L } override fun migrate(realm: DynamicRealm, oldVersion: Long, newVersion: Long) { @@ -52,6 +58,7 @@ class RealmSessionStoreMigration @Inject constructor() : RealmMigration { if (oldVersion <= 6) migrateTo7(realm) if (oldVersion <= 7) migrateTo8(realm) if (oldVersion <= 8) migrateTo9(realm) + if (oldVersion <= 9) migrateTo10(realm) } private fun migrateTo1(realm: DynamicRealm) { @@ -174,7 +181,6 @@ class RealmSessionStoreMigration @Inject constructor() : RealmMigration { ?.addIndex(RoomSummaryEntityFields.IS_SERVER_NOTICE) ?.transform { obj -> - val isFavorite = obj.getList(RoomSummaryEntityFields.TAGS.`$`).any { it.getString(RoomTagEntityFields.TAG_NAME) == RoomTag.ROOM_TAG_FAVOURITE } @@ -194,4 +200,44 @@ class RealmSessionStoreMigration @Inject constructor() : RealmMigration { } } } + + fun migrateTo10(realm: DynamicRealm) { + Timber.d("Step 9 -> 10") + realm.schema.create("SpaceChildSummaryEntity") + ?.addField(SpaceChildSummaryEntityFields.ORDER, String::class.java) + ?.addField(SpaceChildSummaryEntityFields.CHILD_ROOM_ID, String::class.java) + ?.addField(SpaceChildSummaryEntityFields.AUTO_JOIN, Boolean::class.java) + ?.setNullable(SpaceChildSummaryEntityFields.AUTO_JOIN, true) + ?.addRealmObjectField(SpaceChildSummaryEntityFields.CHILD_SUMMARY_ENTITY.`$`, realm.schema.get("RoomSummaryEntity")!!) + ?.addRealmListField(SpaceChildSummaryEntityFields.VIA_SERVERS.`$`, String::class.java) + + realm.schema.create("SpaceParentSummaryEntity") + ?.addField(SpaceParentSummaryEntityFields.PARENT_ROOM_ID, String::class.java) + ?.addField(SpaceParentSummaryEntityFields.CANONICAL, Boolean::class.java) + ?.setNullable(SpaceParentSummaryEntityFields.CANONICAL, true) + ?.addRealmObjectField(SpaceParentSummaryEntityFields.PARENT_SUMMARY_ENTITY.`$`, realm.schema.get("RoomSummaryEntity")!!) + ?.addRealmListField(SpaceParentSummaryEntityFields.VIA_SERVERS.`$`, String::class.java) + + val creationContentAdapter = MoshiProvider.providesMoshi().adapter(RoomCreateContent::class.java) + realm.schema.get("RoomSummaryEntity") + ?.addField(RoomSummaryEntityFields.ROOM_TYPE, String::class.java) + ?.addField(RoomSummaryEntityFields.FLATTEN_PARENT_IDS, String::class.java) + ?.addField(RoomSummaryEntityFields.GROUP_IDS, String::class.java) + ?.transform { obj -> + + val creationEvent = realm.where("CurrentStateEventEntity") + .equalTo(CurrentStateEventEntityFields.ROOM_ID, obj.getString(RoomSummaryEntityFields.ROOM_ID)) + .equalTo(CurrentStateEventEntityFields.TYPE, EventType.STATE_ROOM_CREATE) + .findFirst() + + val roomType = creationEvent?.getObject(CurrentStateEventEntityFields.ROOT.`$`) + ?.getString(EventEntityFields.CONTENT)?.let { + creationContentAdapter.fromJson(it)?.type + } + + obj.setString(RoomSummaryEntityFields.ROOM_TYPE, roomType) + } + ?.addRealmListField(RoomSummaryEntityFields.PARENTS.`$`, realm.schema.get("SpaceParentSummaryEntity")!!) + ?.addRealmListField(RoomSummaryEntityFields.CHILDREN.`$`, realm.schema.get("SpaceChildSummaryEntity")!!) + } } diff --git a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/database/mapper/RoomSummaryMapper.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/database/mapper/RoomSummaryMapper.kt index 6c91f72c7b..92aff0a140 100644 --- a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/database/mapper/RoomSummaryMapper.kt +++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/database/mapper/RoomSummaryMapper.kt @@ -17,6 +17,8 @@ package org.matrix.android.sdk.internal.database.mapper import org.matrix.android.sdk.api.session.room.model.RoomSummary +import org.matrix.android.sdk.api.session.room.model.SpaceChildInfo +import org.matrix.android.sdk.api.session.room.model.SpaceParentInfo import org.matrix.android.sdk.api.session.room.model.tag.RoomTag import org.matrix.android.sdk.internal.database.model.RoomSummaryEntity import org.matrix.android.sdk.internal.session.typing.DefaultTypingUsersTracker @@ -64,7 +66,32 @@ internal class RoomSummaryMapper @Inject constructor(private val timelineEventMa breadcrumbsIndex = roomSummaryEntity.breadcrumbsIndex, roomEncryptionTrustLevel = roomSummaryEntity.roomEncryptionTrustLevel, inviterId = roomSummaryEntity.inviterId, - hasFailedSending = roomSummaryEntity.hasFailedSending + hasFailedSending = roomSummaryEntity.hasFailedSending, + roomType = roomSummaryEntity.roomType, + spaceParents = roomSummaryEntity.parents.map { relationInfoEntity -> + SpaceParentInfo( + parentId = relationInfoEntity.parentRoomId, + roomSummary = relationInfoEntity.parentSummaryEntity?.let { map(it) }, + canonical = relationInfoEntity.canonical ?: false, + viaServers = relationInfoEntity.viaServers.toList() + ) + }, + spaceChildren = roomSummaryEntity.children.map { + SpaceChildInfo( + childRoomId = it.childRoomId ?: "", + isKnown = it.childSummaryEntity != null, + roomType = it.childSummaryEntity?.roomType, + name = it.childSummaryEntity?.name, + topic = it.childSummaryEntity?.topic, + avatarUrl = it.childSummaryEntity?.avatarUrl, + activeMemberCount = it.childSummaryEntity?.joinedMembersCount, + order = it.order, + autoJoin = it.autoJoin ?: false, + viaServers = it.viaServers.toList(), + parentRoomId = roomSummaryEntity.roomId + ) + }, + flattenParentIds = roomSummaryEntity.flattenParentIds?.split("|") ?: emptyList() ) } } diff --git a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/database/model/RoomEntity.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/database/model/RoomEntity.kt index 3ff2532604..58297776f0 100644 --- a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/database/model/RoomEntity.kt +++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/database/model/RoomEntity.kt @@ -43,6 +43,5 @@ internal open class RoomEntity(@PrimaryKey var roomId: String = "", set(value) { membersLoadStatusStr = value.name } - companion object } diff --git a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/database/model/RoomSummaryEntity.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/database/model/RoomSummaryEntity.kt index c87ac15a78..4f47032c4d 100644 --- a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/database/model/RoomSummaryEntity.kt +++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/database/model/RoomSummaryEntity.kt @@ -27,7 +27,10 @@ import org.matrix.android.sdk.api.session.room.model.VersioningState import org.matrix.android.sdk.api.session.room.model.tag.RoomTag internal open class RoomSummaryEntity( - @PrimaryKey var roomId: String = "" + @PrimaryKey var roomId: String = "", + var roomType: String? = null, + var parents: RealmList = RealmList(), + var children: RealmList = RealmList() ) : RealmObject() { var displayName: String? = "" @@ -204,6 +207,16 @@ internal open class RoomSummaryEntity( if (value != field) field = value } + var flattenParentIds: String? = null + set(value) { + if (value != field) field = value + } + + var groupIds: String? = null + set(value) { + if (value != field) field = value + } + @Index private var membershipStr: String = Membership.NONE.name @@ -244,6 +257,5 @@ internal open class RoomSummaryEntity( roomEncryptionTrustLevelStr = value?.name } } - companion object } diff --git a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/database/model/SessionRealmModule.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/database/model/SessionRealmModule.kt index 6e6096cf8a..72ae512fa5 100644 --- a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/database/model/SessionRealmModule.kt +++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/database/model/SessionRealmModule.kt @@ -61,6 +61,8 @@ import io.realm.annotations.RealmModule CurrentStateEventEntity::class, UserAccountDataEntity::class, ScalarTokenEntity::class, - WellknownIntegrationManagerConfigEntity::class + WellknownIntegrationManagerConfigEntity::class, + SpaceChildSummaryEntity::class, + SpaceParentSummaryEntity::class ]) internal class SessionRealmModule diff --git a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/database/model/SpaceChildSummaryEntity.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/database/model/SpaceChildSummaryEntity.kt new file mode 100644 index 0000000000..982c9ece6a --- /dev/null +++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/database/model/SpaceChildSummaryEntity.kt @@ -0,0 +1,44 @@ +/* + * Copyright 2020 The Matrix.org Foundation C.I.C. + * + * 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 org.matrix.android.sdk.internal.database.model + +import io.realm.RealmList +import io.realm.RealmObject + +/** + * Decorates room summary with space related information. + */ +internal open class SpaceChildSummaryEntity( +// var isSpace: Boolean = false, + + var order: String? = null, + + var autoJoin: Boolean? = null, + + var childRoomId: String? = null, + // Link to the actual space summary if it is known locally + var childSummaryEntity: RoomSummaryEntity? = null, + + var viaServers: RealmList = RealmList() +// var owner: RoomSummaryEntity? = null, + +// var level: Int = 0 + +) : RealmObject() { + + companion object +} diff --git a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/database/model/SpaceParentSummaryEntity.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/database/model/SpaceParentSummaryEntity.kt new file mode 100644 index 0000000000..30517717f4 --- /dev/null +++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/database/model/SpaceParentSummaryEntity.kt @@ -0,0 +1,45 @@ +/* + * Copyright 2020 The Matrix.org Foundation C.I.C. + * + * 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 org.matrix.android.sdk.internal.database.model + +import io.realm.RealmList +import io.realm.RealmObject + +/** + * Decorates room summary with space related information. + */ +internal open class SpaceParentSummaryEntity( + /** + * Determines whether this is the main parent for the space + * When a user joins a room with a canonical parent, clients may switch to view the room in the context of that space, + * peeking into it in order to find other rooms and group them together. + * In practice, well behaved rooms should only have one canonical parent, but given this is not enforced: + * if multiple are present the client should select the one with the lowest room ID, + * as determined via a lexicographic utf-8 ordering. + */ + var canonical: Boolean? = null, + + var parentRoomId: String? = null, + // Link to the actual space summary if it is known locally + var parentSummaryEntity: RoomSummaryEntity? = null, + + var viaServers: RealmList = RealmList() + +) : RealmObject() { + + companion object +} diff --git a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/query/QueryRoomOrderProcessor.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/query/QueryRoomOrderProcessor.kt new file mode 100644 index 0000000000..7a06c2129c --- /dev/null +++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/query/QueryRoomOrderProcessor.kt @@ -0,0 +1,37 @@ +/* + * Copyright 2021 The Matrix.org Foundation C.I.C. + * + * 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 org.matrix.android.sdk.internal.query + +import io.realm.RealmQuery +import io.realm.Sort +import org.matrix.android.sdk.api.session.room.RoomSortOrder +import org.matrix.android.sdk.internal.database.model.RoomSummaryEntity +import org.matrix.android.sdk.internal.database.model.RoomSummaryEntityFields + +internal fun RealmQuery.process(sortOrder: RoomSortOrder): RealmQuery { + when (sortOrder) { + RoomSortOrder.NAME -> { + sort(RoomSummaryEntityFields.DISPLAY_NAME, Sort.ASCENDING) + } + RoomSortOrder.ACTIVITY -> { + sort(RoomSummaryEntityFields.LAST_ACTIVITY_TIME, Sort.DESCENDING) + } + RoomSortOrder.NONE -> { + } + } + return this +} diff --git a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/query/QueryStringValueProcessor.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/query/QueryStringValueProcessor.kt index 899024458a..fd33682231 100644 --- a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/query/QueryStringValueProcessor.kt +++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/query/QueryStringValueProcessor.kt @@ -16,10 +16,10 @@ package org.matrix.android.sdk.internal.query -import org.matrix.android.sdk.api.query.QueryStringValue import io.realm.Case import io.realm.RealmObject import io.realm.RealmQuery +import org.matrix.android.sdk.api.query.QueryStringValue import timber.log.Timber fun RealmQuery.process(field: String, queryStringValue: QueryStringValue): RealmQuery { diff --git a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/DefaultSession.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/DefaultSession.kt index 821a9cba8c..6c574be826 100644 --- a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/DefaultSession.kt +++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/DefaultSession.kt @@ -49,6 +49,7 @@ import org.matrix.android.sdk.api.session.search.SearchService import org.matrix.android.sdk.api.session.securestorage.SecureStorageService import org.matrix.android.sdk.api.session.securestorage.SharedSecretStorageService import org.matrix.android.sdk.api.session.signout.SignOutService +import org.matrix.android.sdk.api.session.space.SpaceService import org.matrix.android.sdk.api.session.sync.FilterService import org.matrix.android.sdk.api.session.terms.TermsService import org.matrix.android.sdk.api.session.thirdparty.ThirdPartyService @@ -120,6 +121,7 @@ internal class DefaultSession @Inject constructor( private val integrationManagerService: IntegrationManagerService, private val thirdPartyService: Lazy, private val callSignalingService: Lazy, + private val spaceService: Lazy, @UnauthenticatedWithCertificate private val unauthenticatedWithCertificateOkHttpClient: Lazy ) : Session, @@ -265,6 +267,8 @@ internal class DefaultSession @Inject constructor( override fun thirdPartyService(): ThirdPartyService = thirdPartyService.get() + override fun spaceService(): SpaceService = spaceService.get() + override fun getOkHttpClient(): OkHttpClient { return unauthenticatedWithCertificateOkHttpClient.get() } diff --git a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/SessionComponent.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/SessionComponent.kt index 7e1e3d0f70..541c877b1d 100644 --- a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/SessionComponent.kt +++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/SessionComponent.kt @@ -52,6 +52,7 @@ import org.matrix.android.sdk.internal.session.room.send.RedactEventWorker import org.matrix.android.sdk.internal.session.room.send.SendEventWorker import org.matrix.android.sdk.internal.session.search.SearchModule import org.matrix.android.sdk.internal.session.signout.SignOutModule +import org.matrix.android.sdk.internal.session.space.SpaceModule import org.matrix.android.sdk.internal.session.sync.SyncModule import org.matrix.android.sdk.internal.session.sync.SyncTask import org.matrix.android.sdk.internal.session.sync.SyncTokenStore @@ -91,7 +92,8 @@ import org.matrix.android.sdk.internal.util.MatrixCoroutineDispatchers FederationModule::class, CallModule::class, SearchModule::class, - ThirdPartyModule::class + ThirdPartyModule::class, + SpaceModule::class ] ) @SessionScope diff --git a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/room/DefaultRoom.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/room/DefaultRoom.kt index 1d8eb6c95e..c6059f84ea 100644 --- a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/room/DefaultRoom.kt +++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/room/DefaultRoom.kt @@ -24,6 +24,7 @@ import org.matrix.android.sdk.api.session.room.alias.AliasService import org.matrix.android.sdk.api.session.room.call.RoomCallService import org.matrix.android.sdk.api.session.room.members.MembershipService import org.matrix.android.sdk.api.session.room.model.RoomSummary +import org.matrix.android.sdk.api.session.room.model.RoomType import org.matrix.android.sdk.api.session.room.model.relation.RelationService import org.matrix.android.sdk.api.session.room.notification.RoomPushRuleService import org.matrix.android.sdk.api.session.room.read.ReadService @@ -36,11 +37,13 @@ import org.matrix.android.sdk.api.session.room.timeline.TimelineService import org.matrix.android.sdk.api.session.room.typing.TypingService import org.matrix.android.sdk.api.session.room.uploads.UploadsService import org.matrix.android.sdk.api.session.search.SearchResult +import org.matrix.android.sdk.api.session.space.Space import org.matrix.android.sdk.api.util.Optional import org.matrix.android.sdk.internal.crypto.MXCRYPTO_ALGORITHM_MEGOLM import org.matrix.android.sdk.internal.session.room.state.SendStateTask import org.matrix.android.sdk.internal.session.room.summary.RoomSummaryDataSource import org.matrix.android.sdk.internal.session.search.SearchTask +import org.matrix.android.sdk.internal.session.space.DefaultSpace import org.matrix.android.sdk.internal.util.awaitCallback import java.security.InvalidParameterException import javax.inject.Inject @@ -148,4 +151,9 @@ internal class DefaultRoom @Inject constructor(override val roomId: String, ) ) } + + override fun asSpace(): Space? { + if (roomSummary()?.roomType != RoomType.SPACE) return null + return DefaultSpace(this, roomSummaryDataSource) + } } diff --git a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/room/DefaultRoomService.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/room/DefaultRoomService.kt index 22f61bc517..d9fe1288e2 100644 --- a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/room/DefaultRoomService.kt +++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/room/DefaultRoomService.kt @@ -23,9 +23,11 @@ import com.zhuinden.monarchy.Monarchy import org.matrix.android.sdk.api.session.events.model.Event import org.matrix.android.sdk.api.session.room.Room import org.matrix.android.sdk.api.session.room.RoomService +import org.matrix.android.sdk.api.session.room.RoomSortOrder import org.matrix.android.sdk.api.session.room.RoomSummaryQueryParams -import org.matrix.android.sdk.api.session.room.UpdatableFilterLivePageResult +import org.matrix.android.sdk.api.session.room.UpdatableLivePageResult import org.matrix.android.sdk.api.session.room.members.ChangeMembershipState +import org.matrix.android.sdk.api.session.room.model.Membership import org.matrix.android.sdk.api.session.room.model.RoomMemberSummary import org.matrix.android.sdk.api.session.room.model.RoomSummary import org.matrix.android.sdk.api.session.room.model.create.CreateRoomParams @@ -67,7 +69,7 @@ internal class DefaultRoomService @Inject constructor( ) : RoomService { override suspend fun createRoom(createRoomParams: CreateRoomParams): String { - return createRoomTask.execute(createRoomParams) + return createRoomTask.executeRetry(createRoomParams, 3) } override fun getRoom(roomId: String): Room? { @@ -90,14 +92,14 @@ internal class DefaultRoomService @Inject constructor( return roomSummaryDataSource.getRoomSummariesLive(queryParams) } - override fun getPagedRoomSummariesLive(queryParams: RoomSummaryQueryParams, pagedListConfig: PagedList.Config) + override fun getPagedRoomSummariesLive(queryParams: RoomSummaryQueryParams, pagedListConfig: PagedList.Config, sortOrder: RoomSortOrder) : LiveData> { - return roomSummaryDataSource.getSortedPagedRoomSummariesLive(queryParams, pagedListConfig) + return roomSummaryDataSource.getSortedPagedRoomSummariesLive(queryParams, pagedListConfig, sortOrder) } - override fun getFilteredPagedRoomSummariesLive(queryParams: RoomSummaryQueryParams, pagedListConfig: PagedList.Config) - : UpdatableFilterLivePageResult { - return roomSummaryDataSource.getFilteredPagedRoomSummariesLive(queryParams, pagedListConfig) + override fun getFilteredPagedRoomSummariesLive(queryParams: RoomSummaryQueryParams, pagedListConfig: PagedList.Config, sortOrder: RoomSortOrder) + : UpdatableLivePageResult { + return roomSummaryDataSource.getUpdatablePagedRoomSummariesLive(queryParams, pagedListConfig, sortOrder) } override fun getNotificationCountForRooms(queryParams: RoomSummaryQueryParams): RoomAggregateNotificationCount { @@ -163,4 +165,18 @@ internal class DefaultRoomService @Inject constructor( override suspend fun peekRoom(roomIdOrAlias: String): PeekResult { return peekRoomTask.execute(PeekRoomTask.Params(roomIdOrAlias)) } + + override fun getFlattenRoomSummaryChildrenOf(spaceId: String?, memberships: List): List { + if (spaceId == null) { + return roomSummaryDataSource.getFlattenOrphanRooms() + } + return roomSummaryDataSource.getAllRoomSummaryChildOf(spaceId, memberships) + } + + override fun getFlattenRoomSummaryChildrenOfLive(spaceId: String?, memberships: List): LiveData> { + if (spaceId == null) { + return roomSummaryDataSource.getFlattenOrphanRoomsLive() + } + return roomSummaryDataSource.getAllRoomSummaryChildOfLive(spaceId, memberships) + } } diff --git a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/room/RoomModule.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/room/RoomModule.kt index 5133f72932..8f3445bec3 100644 --- a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/room/RoomModule.kt +++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/room/RoomModule.kt @@ -24,6 +24,7 @@ import org.commonmark.renderer.html.HtmlRenderer import org.matrix.android.sdk.api.session.file.FileService import org.matrix.android.sdk.api.session.room.RoomDirectoryService import org.matrix.android.sdk.api.session.room.RoomService +import org.matrix.android.sdk.api.session.space.SpaceService import org.matrix.android.sdk.internal.session.DefaultFileService import org.matrix.android.sdk.internal.session.SessionScope import org.matrix.android.sdk.internal.session.directory.DirectoryAPI @@ -89,6 +90,7 @@ import org.matrix.android.sdk.internal.session.room.typing.DefaultSendTypingTask import org.matrix.android.sdk.internal.session.room.typing.SendTypingTask import org.matrix.android.sdk.internal.session.room.uploads.DefaultGetUploadsTask import org.matrix.android.sdk.internal.session.room.uploads.GetUploadsTask +import org.matrix.android.sdk.internal.session.space.DefaultSpaceService import retrofit2.Retrofit @Module @@ -135,6 +137,9 @@ internal abstract class RoomModule { @Binds abstract fun bindRoomService(service: DefaultRoomService): RoomService + @Binds + abstract fun bindSpaceService(service: DefaultSpaceService): SpaceService + @Binds abstract fun bindRoomDirectoryService(service: DefaultRoomDirectoryService): RoomDirectoryService diff --git a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/room/SpaceGetter.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/room/SpaceGetter.kt new file mode 100644 index 0000000000..fed3ff542b --- /dev/null +++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/room/SpaceGetter.kt @@ -0,0 +1,33 @@ +/* + * Copyright 2021 The Matrix.org Foundation C.I.C. + * + * 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 org.matrix.android.sdk.internal.session.room + +import org.matrix.android.sdk.api.session.space.Space +import javax.inject.Inject + +internal interface SpaceGetter { + fun get(spaceId: String): Space? +} + +internal class DefaultSpaceGetter @Inject constructor( + private val roomGetter: RoomGetter +) : SpaceGetter { + + override fun get(spaceId: String): Space? { + return roomGetter.getRoom(spaceId)?.asSpace() + } +} diff --git a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/room/alias/RoomAliasAvailabilityChecker.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/room/alias/RoomAliasAvailabilityChecker.kt index 9faf50dd8b..b39cbaa582 100644 --- a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/room/alias/RoomAliasAvailabilityChecker.kt +++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/room/alias/RoomAliasAvailabilityChecker.kt @@ -36,7 +36,11 @@ internal class RoomAliasAvailabilityChecker @Inject constructor( @Throws(RoomAliasError::class) suspend fun check(aliasLocalPart: String?) { if (aliasLocalPart.isNullOrEmpty()) { - throw RoomAliasError.AliasEmpty + // don't check empty or not provided alias + return + } + if (aliasLocalPart.isBlank()) { + throw RoomAliasError.AliasIsBlank } // Check alias availability val fullAlias = aliasLocalPart.toFullLocalAlias(userId) diff --git a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/room/create/CreateRoomBody.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/room/create/CreateRoomBody.kt index 13d403e2e4..69352688e3 100644 --- a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/room/create/CreateRoomBody.kt +++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/room/create/CreateRoomBody.kt @@ -111,5 +111,12 @@ internal data class CreateRoomBody( * The power level content to override in the default power level event */ @Json(name = "power_level_content_override") - val powerLevelContentOverride: PowerLevelsContent? + val powerLevelContentOverride: PowerLevelsContent?, + + /** + * The room version to set for the room. If not provided, the homeserver is to use its configured default. + * If provided, the homeserver will return a 400 error with the errcode M_UNSUPPORTED_ROOM_VERSION if it does not support the room version. + */ + @Json(name = "room_version") + val roomVersion: String? ) diff --git a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/room/create/CreateRoomBodyBuilder.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/room/create/CreateRoomBodyBuilder.kt index 80be49de61..018b865388 100644 --- a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/room/create/CreateRoomBodyBuilder.kt +++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/room/create/CreateRoomBodyBuilder.kt @@ -20,8 +20,13 @@ import org.matrix.android.sdk.api.extensions.tryOrNull import org.matrix.android.sdk.api.session.crypto.crosssigning.CrossSigningService import org.matrix.android.sdk.api.session.events.model.Event import org.matrix.android.sdk.api.session.events.model.EventType +import org.matrix.android.sdk.api.session.events.model.toContent import org.matrix.android.sdk.api.session.identity.IdentityServiceError import org.matrix.android.sdk.api.session.identity.toMedium +import org.matrix.android.sdk.api.session.room.model.GuestAccess +import org.matrix.android.sdk.api.session.room.model.RoomHistoryVisibility +import org.matrix.android.sdk.api.session.room.model.RoomJoinRules +import org.matrix.android.sdk.api.session.room.model.RoomJoinRulesContent import org.matrix.android.sdk.api.session.room.model.create.CreateRoomParams import org.matrix.android.sdk.api.util.MimeTypes import org.matrix.android.sdk.internal.crypto.DeviceListManager @@ -71,10 +76,17 @@ internal class CreateRoomBodyBuilder @Inject constructor( } } + if (params.joinRuleRestricted != null) { + params.roomVersion = "org.matrix.msc3083" + params.historyVisibility = params.historyVisibility ?: RoomHistoryVisibility.SHARED + params.guestAccess = params.guestAccess ?: GuestAccess.Forbidden + } val initialStates = listOfNotNull( buildEncryptionWithAlgorithmEvent(params), buildHistoryVisibilityEvent(params), - buildAvatarEvent(params) + buildAvatarEvent(params), + buildGuestAccess(params), + buildJoinRulesRestricted(params) ) .takeIf { it.isNotEmpty() } @@ -89,7 +101,9 @@ internal class CreateRoomBodyBuilder @Inject constructor( initialStates = initialStates, preset = params.preset, isDirect = params.isDirect, - powerLevelContentOverride = params.powerLevelContentOverride + powerLevelContentOverride = params.powerLevelContentOverride, + roomVersion = params.roomVersion + ) } @@ -123,6 +137,31 @@ internal class CreateRoomBodyBuilder @Inject constructor( } } + private fun buildGuestAccess(params: CreateRoomParams): Event? { + return params.guestAccess + ?.let { + Event( + type = EventType.STATE_ROOM_GUEST_ACCESS, + stateKey = "", + content = mapOf("guest_access" to it.value) + ) + } + } + + private fun buildJoinRulesRestricted(params: CreateRoomParams): Event? { + return params.joinRuleRestricted + ?.let { allowList -> + Event( + type = EventType.STATE_ROOM_JOIN_RULES, + stateKey = "", + content = RoomJoinRulesContent( + _joinRules = RoomJoinRules.RESTRICTED.value, + allowList = allowList + ).toContent() + ) + } + } + /** * Add the crypto algorithm to the room creation parameters. */ diff --git a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/room/create/CreateRoomTask.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/room/create/CreateRoomTask.kt index bafe2b90ae..de6a71e581 100644 --- a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/room/create/CreateRoomTask.kt +++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/room/create/CreateRoomTask.kt @@ -102,7 +102,7 @@ internal class DefaultCreateRoomTask @Inject constructor( .equalTo(RoomSummaryEntityFields.MEMBERSHIP_STR, Membership.JOIN.name) } } catch (exception: TimeoutCancellationException) { - throw CreateRoomFailure.CreatedWithTimeout + throw CreateRoomFailure.CreatedWithTimeout(roomId) } Realm.getInstance(realmConfiguration).executeTransactionAsync { diff --git a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/room/peeking/PeekRoomTask.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/room/peeking/PeekRoomTask.kt index 5b211c505f..c6f4bbb4e1 100644 --- a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/room/peeking/PeekRoomTask.kt +++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/room/peeking/PeekRoomTask.kt @@ -23,11 +23,14 @@ import org.matrix.android.sdk.api.session.events.model.toModel import org.matrix.android.sdk.api.session.room.model.RoomAvatarContent import org.matrix.android.sdk.api.session.room.model.RoomCanonicalAliasContent import org.matrix.android.sdk.api.session.room.model.RoomDirectoryVisibility +import org.matrix.android.sdk.api.session.room.model.RoomMemberContent import org.matrix.android.sdk.api.session.room.model.RoomNameContent import org.matrix.android.sdk.api.session.room.model.RoomTopicContent +import org.matrix.android.sdk.api.session.room.model.create.RoomCreateContent import org.matrix.android.sdk.api.session.room.model.roomdirectory.PublicRoomsFilter import org.matrix.android.sdk.api.session.room.model.roomdirectory.PublicRoomsParams import org.matrix.android.sdk.api.session.room.peeking.PeekResult +import org.matrix.android.sdk.api.util.MatrixItem import org.matrix.android.sdk.internal.session.room.alias.GetRoomIdByAliasTask import org.matrix.android.sdk.internal.session.room.directory.GetPublicRoomTask import org.matrix.android.sdk.internal.session.room.directory.GetRoomDirectoryVisibilityTask @@ -100,7 +103,9 @@ internal class DefaultPeekRoomTask @Inject constructor( name = publicRepoResult.name, topic = publicRepoResult.topic, numJoinedMembers = publicRepoResult.numJoinedMembers, - viaServers = serverList + viaServers = serverList, + roomType = null, // would be nice to get that from directory... + someMembers = null ) } @@ -125,11 +130,25 @@ internal class DefaultPeekRoomTask @Inject constructor( ?.let { it.content?.toModel()?.canonicalAlias } // not sure if it's the right way to do that :/ - val memberCount = stateEvents + val membersEvent = stateEvents .filter { it.type == EventType.STATE_ROOM_MEMBER && it.stateKey?.isNotEmpty() == true } + + val memberCount = membersEvent .distinctBy { it.stateKey } .count() + val someMembers = membersEvent.mapNotNull { ev -> + ev.content?.toModel()?.let { + MatrixItem.UserItem(ev.stateKey ?: "", it.displayName, it.avatarUrl) + } + } + + val roomType = stateEvents + .lastOrNull { it.type == EventType.STATE_ROOM_CREATE } + ?.content + ?.toModel() + ?.type + return PeekResult.Success( roomId = roomId, alias = alias, @@ -137,7 +156,9 @@ internal class DefaultPeekRoomTask @Inject constructor( name = name, topic = topic, numJoinedMembers = memberCount, - viaServers = serverList + roomType = roomType, + viaServers = serverList, + someMembers = someMembers ) } catch (failure: Throwable) { // Would be M_FORBIDDEN if cannot peek :/ diff --git a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/room/relationship/RoomChildRelationInfo.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/room/relationship/RoomChildRelationInfo.kt new file mode 100644 index 0000000000..2efea7f118 --- /dev/null +++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/room/relationship/RoomChildRelationInfo.kt @@ -0,0 +1,104 @@ +/* + * Copyright 2020 The Matrix.org Foundation C.I.C. + * + * 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 org.matrix.android.sdk.internal.session.room.relationship + +import io.realm.Realm +import org.matrix.android.sdk.api.session.events.model.EventType +import org.matrix.android.sdk.api.session.events.model.toModel +import org.matrix.android.sdk.api.session.space.model.SpaceChildContent +import org.matrix.android.sdk.api.session.space.model.SpaceParentContent +import org.matrix.android.sdk.internal.database.mapper.ContentMapper +import org.matrix.android.sdk.internal.database.model.CurrentStateEventEntity +import org.matrix.android.sdk.internal.database.query.whereType + +/** + * Relationship between rooms and spaces + * The intention is that rooms and spaces form a hierarchy, which clients can use to structure the user's room list into a tree view. + * The parent/child relationship can be expressed in one of two ways: + * - The admins of a space can advertise rooms and subspaces for their space by setting m.space.child state events. + * The state_key is the ID of a child room or space, and the content should contain a via key which gives + * a list of candidate servers that can be used to join the room. present: true key is included to distinguish from a deleted state event. + * + * - Separately, rooms can claim parents via the m.room.parent state event. + */ +internal class RoomChildRelationInfo( + private val realm: Realm, + private val roomId: String +) { + + data class SpaceChildInfo( + val roomId: String, + val order: String?, + val autoJoin: Boolean, + val viaServers: List + ) + + data class SpaceParentInfo( + val roomId: String, + val canonical: Boolean, + val viaServers: List, + val stateEventSender: String + ) + + /** + * Gets the ordered list of valid child description. + */ + fun getDirectChildrenDescriptions(): List { + return CurrentStateEventEntity.whereType(realm, roomId, EventType.STATE_SPACE_CHILD) + .findAll() +// .also { +// Timber.v("## Space: Found ${it.count()} m.space.child state events for $roomId") +// } + .mapNotNull { + ContentMapper.map(it.root?.content).toModel()?.let { scc -> +// Timber.v("## Space child desc state event $scc") + // Children where via is not present are ignored. + scc.via?.let { via -> + SpaceChildInfo( + roomId = it.stateKey, + order = scc.validOrder(), + autoJoin = scc.autoJoin ?: false, + viaServers = via + ) + } + } + } + .sortedBy { it.order } + } + + fun getParentDescriptions(): List { + return CurrentStateEventEntity.whereType(realm, roomId, EventType.STATE_SPACE_PARENT) + .findAll() +// .also { +// Timber.v("## Space: Found ${it.count()} m.space.parent state events for $roomId") +// } + .mapNotNull { + ContentMapper.map(it.root?.content).toModel()?.let { scc -> +// Timber.v("## Space parent desc state event $scc") + // Parent where via is not present are ignored. + scc.via?.let { via -> + SpaceParentInfo( + roomId = it.stateKey, + canonical = scc.canonical ?: false, + viaServers = via, + stateEventSender = it.root?.sender ?: "" + ) + } + } + } + } +} diff --git a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/room/state/DefaultStateService.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/room/state/DefaultStateService.kt index 615bc99096..ff2afb5d61 100644 --- a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/room/state/DefaultStateService.kt +++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/room/state/DefaultStateService.kt @@ -34,6 +34,7 @@ import org.matrix.android.sdk.api.util.JsonDict import org.matrix.android.sdk.api.util.MimeTypes import org.matrix.android.sdk.api.util.Optional import org.matrix.android.sdk.internal.session.content.FileUploader +import java.lang.UnsupportedOperationException internal class DefaultStateService @AssistedInject constructor(@Assisted private val roomId: String, private val stateEventDataSource: StateEventDataSource, @@ -73,7 +74,7 @@ internal class DefaultStateService @AssistedInject constructor(@Assisted private eventType = eventType, body = body.toSafeJson(eventType) ) - sendStateTask.execute(params) + sendStateTask.executeRetry(params, 3) } private fun JsonDict.toSafeJson(eventType: String): JsonDict { @@ -127,6 +128,7 @@ internal class DefaultStateService @AssistedInject constructor(@Assisted private override suspend fun updateJoinRule(joinRules: RoomJoinRules?, guestAccess: GuestAccess?) { if (joinRules != null) { + if (joinRules == RoomJoinRules.RESTRICTED) throw UnsupportedOperationException("No yet supported") sendStateEvent( eventType = EventType.STATE_ROOM_JOIN_RULES, body = mapOf("join_rule" to joinRules), diff --git a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/room/state/SafePowerLevelContent.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/room/state/SafePowerLevelContent.kt index a97709e38b..197b4f8688 100644 --- a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/room/state/SafePowerLevelContent.kt +++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/room/state/SafePowerLevelContent.kt @@ -21,22 +21,21 @@ import com.squareup.moshi.JsonClass import org.matrix.android.sdk.api.session.events.model.toContent import org.matrix.android.sdk.api.session.events.model.toModel import org.matrix.android.sdk.api.session.room.model.PowerLevelsContent -import org.matrix.android.sdk.api.session.room.powerlevels.Role import org.matrix.android.sdk.api.util.JsonDict @JsonClass(generateAdapter = true) internal data class SerializablePowerLevelsContent( - @Json(name = "ban") val ban: Int = Role.Moderator.value, - @Json(name = "kick") val kick: Int = Role.Moderator.value, - @Json(name = "invite") val invite: Int = Role.Moderator.value, - @Json(name = "redact") val redact: Int = Role.Moderator.value, - @Json(name = "events_default") val eventsDefault: Int = Role.Default.value, - @Json(name = "events") val events: Map = emptyMap(), - @Json(name = "users_default") val usersDefault: Int = Role.Default.value, - @Json(name = "users") val users: Map = emptyMap(), - @Json(name = "state_default") val stateDefault: Int = Role.Moderator.value, + @Json(name = "ban") val ban: Int?, + @Json(name = "kick") val kick: Int?, + @Json(name = "invite") val invite: Int?, + @Json(name = "redact") val redact: Int?, + @Json(name = "events_default") val eventsDefault: Int?, + @Json(name = "events") val events: Map?, + @Json(name = "users_default") val usersDefault: Int?, + @Json(name = "users") val users: Map?, + @Json(name = "state_default") val stateDefault: Int?, // `Int` is the diff here (instead of `Any`) - @Json(name = "notifications") val notifications: Map = emptyMap() + @Json(name = "notifications") val notifications: Map? ) internal fun JsonDict.toSafePowerLevelsContentDict(): JsonDict { @@ -52,7 +51,7 @@ internal fun JsonDict.toSafePowerLevelsContentDict(): JsonDict { usersDefault = content.usersDefault, users = content.users, stateDefault = content.stateDefault, - notifications = content.notifications.mapValues { content.notificationLevel(it.key) } + notifications = content.notifications?.mapValues { content.notificationLevel(it.key) } ) } ?.toContent() diff --git a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/room/summary/GraphUtils.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/room/summary/GraphUtils.kt new file mode 100644 index 0000000000..b7e6548b54 --- /dev/null +++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/room/summary/GraphUtils.kt @@ -0,0 +1,166 @@ +/* + * Copyright (c) 2021 The Matrix.org Foundation C.I.C. + * + * 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 org.matrix.android.sdk.internal.session.room.summary + +import java.util.LinkedList + +data class GraphNode( + val name: String +) + +data class GraphEdge( + val source: GraphNode, + val destination: GraphNode +) + +class Graph { + + private val adjacencyList: HashMap> = HashMap() + + fun getOrCreateNode(name: String): GraphNode { + return adjacencyList.entries.firstOrNull { it.key.name == name }?.key + ?: GraphNode(name).also { + adjacencyList[it] = ArrayList() + } + } + + fun addEdge(sourceName: String, destinationName: String) { + val source = getOrCreateNode(sourceName) + val destination = getOrCreateNode(destinationName) + adjacencyList.getOrPut(source) { ArrayList() }.add( + GraphEdge(source, destination) + ) + } + + fun addEdge(source: GraphNode, destination: GraphNode) { + adjacencyList.getOrPut(source) { ArrayList() }.add( + GraphEdge(source, destination) + ) + } + + fun edgesOf(node: GraphNode): List { + return adjacencyList[node]?.toList() ?: emptyList() + } + + fun withoutEdges(edgesToPrune: List): Graph { + val output = Graph() + this.adjacencyList.forEach { (vertex, edges) -> + output.getOrCreateNode(vertex.name) + edges.forEach { + if (!edgesToPrune.contains(it)) { + // add it + output.addEdge(it.source, it.destination) + } + } + } + return output + } + + /** + * Depending on the chosen starting point the background edge might change + */ + fun findBackwardEdges(startFrom: GraphNode? = null): List { + val backwardEdges = mutableSetOf() + val visited = mutableMapOf() + val notVisited = -1 + val inPath = 0 + val completed = 1 + adjacencyList.keys.forEach { + visited[it] = notVisited + } + val stack = LinkedList() + + (startFrom ?: adjacencyList.entries.firstOrNull { visited[it.key] == notVisited }?.key) + ?.let { + stack.push(it) + visited[it] = inPath + } + + while (stack.isNotEmpty()) { +// Timber.w("VAL: current stack: ${stack.reversed().joinToString { it.name }}") + val vertex = stack.peek() ?: break + // peek a path to follow + var destination: GraphNode? = null + edgesOf(vertex).forEach { + when (visited[it.destination]) { + notVisited -> { + // it's a candidate + destination = it.destination + } + inPath -> { + // Cycle!! + backwardEdges.add(it) + } + completed -> { + // dead end + } + } + } + if (destination == null) { + // dead end, pop + stack.pop().let { + visited[it] = completed + } + } else { + // go down this path + stack.push(destination) + visited[destination!!] = inPath + } + + if (stack.isEmpty()) { + // try to get another graph of forest? + adjacencyList.entries.firstOrNull { visited[it.key] == notVisited }?.key?.let { + stack.push(it) + visited[it] = inPath + } + } + } + + return backwardEdges.toList() + } + + /** + * Only call that on acyclic graph! + */ + fun flattenDestination(): Map> { + val result = HashMap>() + adjacencyList.keys.forEach { vertex -> + result[vertex] = flattenOf(vertex) + } + return result + } + + private fun flattenOf(node: GraphNode): Set { + val result = mutableSetOf() + val edgesOf = edgesOf(node) + result.addAll(edgesOf.map { it.destination }) + edgesOf.forEach { + result.addAll(flattenOf(it.destination)) + } + return result + } + + override fun toString(): String { + return buildString { + adjacencyList.forEach { (node, edges) -> + append("${node.name} : [") + append(edges.joinToString(" ") { it.destination.name }) + append("]\n") + } + } + } +} diff --git a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/room/summary/HierarchyLiveDataHelper.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/room/summary/HierarchyLiveDataHelper.kt new file mode 100644 index 0000000000..29db8431fd --- /dev/null +++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/room/summary/HierarchyLiveDataHelper.kt @@ -0,0 +1,67 @@ +/* + * Copyright 2021 The Matrix.org Foundation C.I.C. + * + * 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 org.matrix.android.sdk.internal.session.room.summary + +import androidx.lifecycle.LiveData +import androidx.lifecycle.MediatorLiveData +import org.matrix.android.sdk.api.session.room.model.Membership +import org.matrix.android.sdk.api.session.room.model.RoomSummary +import org.matrix.android.sdk.api.util.Optional + +internal class HierarchyLiveDataHelper( + val spaceId: String, + val memberships: List, + val roomSummaryDataSource: RoomSummaryDataSource) { + + private val sources = HashMap>>() + private val mediatorLiveData = MediatorLiveData>() + + fun liveData(): LiveData> = mediatorLiveData + + init { + onChange() + } + + private fun parentsToCheck(): List { + val spaces = ArrayList() + roomSummaryDataSource.getSpaceSummary(spaceId)?.let { + roomSummaryDataSource.flattenSubSpace(it, emptyList(), spaces, memberships) + } + return spaces + } + + private fun onChange() { + val existingSources = sources.keys.toList() + val newSources = parentsToCheck().map { it.roomId } + val addedSources = newSources.filter { !existingSources.contains(it) } + val removedSource = existingSources.filter { !newSources.contains(it) } + addedSources.forEach { + val liveData = roomSummaryDataSource.getSpaceSummaryLive(it) + mediatorLiveData.addSource(liveData) { onChange() } + sources[it] = liveData + } + + removedSource.forEach { + sources[it]?.let { mediatorLiveData.removeSource(it) } + } + + sources[spaceId]?.value?.getOrNull()?.let { spaceSummary -> + val results = ArrayList() + roomSummaryDataSource.flattenChild(spaceSummary, emptyList(), results, memberships) + mediatorLiveData.postValue(results.map { it.roomId }) + } + } +} diff --git a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/room/summary/RoomSummaryDataSource.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/room/summary/RoomSummaryDataSource.kt index dd3fbe04b2..d2bb51cbef 100644 --- a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/room/summary/RoomSummaryDataSource.kt +++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/room/summary/RoomSummaryDataSource.kt @@ -1,5 +1,6 @@ /* * Copyright 2020 The Matrix.org Foundation C.I.C. + * Copyright 2021 The Matrix.org Foundation C.I.C. * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -24,12 +25,20 @@ import com.zhuinden.monarchy.Monarchy import io.realm.Realm import io.realm.RealmQuery import io.realm.Sort +import io.realm.kotlin.where +import org.matrix.android.sdk.api.query.ActiveSpaceFilter import org.matrix.android.sdk.api.query.RoomCategoryFilter +import org.matrix.android.sdk.api.session.room.RoomSortOrder import org.matrix.android.sdk.api.session.room.RoomSummaryQueryParams -import org.matrix.android.sdk.api.session.room.UpdatableFilterLivePageResult +import org.matrix.android.sdk.api.session.room.UpdatableLivePageResult +import org.matrix.android.sdk.api.session.room.model.Membership import org.matrix.android.sdk.api.session.room.model.RoomSummary +import org.matrix.android.sdk.api.session.room.model.RoomType import org.matrix.android.sdk.api.session.room.model.VersioningState +import org.matrix.android.sdk.api.session.room.roomSummaryQueryParams +import org.matrix.android.sdk.api.session.room.spaceSummaryQueryParams import org.matrix.android.sdk.api.session.room.summary.RoomAggregateNotificationCount +import org.matrix.android.sdk.api.session.space.SpaceSummaryQueryParams import org.matrix.android.sdk.api.util.Optional import org.matrix.android.sdk.api.util.toOptional import org.matrix.android.sdk.internal.database.mapper.RoomSummaryMapper @@ -79,11 +88,60 @@ internal class RoomSummaryDataSource @Inject constructor(@SessionDatabase privat fun getRoomSummariesLive(queryParams: RoomSummaryQueryParams): LiveData> { return monarchy.findAllMappedWithChanges( - { roomSummariesQuery(it, queryParams) }, + { + roomSummariesQuery(it, queryParams) + .sort(RoomSummaryEntityFields.LAST_ACTIVITY_TIME, Sort.DESCENDING) + }, { roomSummaryMapper.map(it) } ) } + fun getSpaceSummariesLive(queryParams: SpaceSummaryQueryParams): LiveData> { + return getRoomSummariesLive(queryParams) + } + + fun getSpaceSummary(roomIdOrAlias: String): RoomSummary? { + return getRoomSummary(roomIdOrAlias) + ?.takeIf { it.roomType == RoomType.SPACE } + } + + fun getSpaceSummaryLive(roomId: String): LiveData> { + val liveData = monarchy.findAllMappedWithChanges( + { realm -> + RoomSummaryEntity.where(realm, roomId) + .isNotEmpty(RoomSummaryEntityFields.DISPLAY_NAME) + .equalTo(RoomSummaryEntityFields.ROOM_TYPE, RoomType.SPACE) + }, + { + roomSummaryMapper.map(it) + } + ) + return Transformations.map(liveData) { results -> + results.firstOrNull().toOptional() + } + } + + fun getSpaceSummaries(spaceSummaryQueryParams: SpaceSummaryQueryParams): List { + return getRoomSummaries(spaceSummaryQueryParams) + } + + fun getRootSpaceSummaries(): List { + return getRoomSummaries(spaceSummaryQueryParams { + memberships = listOf(Membership.JOIN) + }) + .let { allJoinedSpace -> + val allFlattenChildren = arrayListOf() + allJoinedSpace.forEach { + flattenSubSpace(it, emptyList(), allFlattenChildren, listOf(Membership.JOIN), false) + } + val knownNonOrphan = allFlattenChildren.map { it.roomId }.distinct() + // keep only root rooms + allJoinedSpace.filter { candidate -> + !knownNonOrphan.contains(candidate.roomId) + } + } + } + fun getBreadcrumbs(queryParams: RoomSummaryQueryParams): List { return monarchy.fetchAllMappedSync( { breadcrumbsQuery(it, queryParams) }, @@ -105,10 +163,10 @@ internal class RoomSummaryDataSource @Inject constructor(@SessionDatabase privat } fun getSortedPagedRoomSummariesLive(queryParams: RoomSummaryQueryParams, - pagedListConfig: PagedList.Config): LiveData> { + pagedListConfig: PagedList.Config, + sortOrder: RoomSortOrder): LiveData> { val realmDataSourceFactory = monarchy.createDataSourceFactory { realm -> - roomSummariesQuery(realm, queryParams) - .sort(RoomSummaryEntityFields.LAST_ACTIVITY_TIME, Sort.DESCENDING) + roomSummariesQuery(realm, queryParams).process(sortOrder) } val dataSourceFactory = realmDataSourceFactory.map { roomSummaryMapper.map(it) @@ -119,11 +177,11 @@ internal class RoomSummaryDataSource @Inject constructor(@SessionDatabase privat ) } - fun getFilteredPagedRoomSummariesLive(queryParams: RoomSummaryQueryParams, - pagedListConfig: PagedList.Config): UpdatableFilterLivePageResult { + fun getUpdatablePagedRoomSummariesLive(queryParams: RoomSummaryQueryParams, + pagedListConfig: PagedList.Config, + sortOrder: RoomSortOrder): UpdatableLivePageResult { val realmDataSourceFactory = monarchy.createDataSourceFactory { realm -> - roomSummariesQuery(realm, queryParams) - .sort(RoomSummaryEntityFields.LAST_ACTIVITY_TIME, Sort.DESCENDING) + roomSummariesQuery(realm, queryParams).process(sortOrder) } val dataSourceFactory = realmDataSourceFactory.map { roomSummaryMapper.map(it) @@ -134,13 +192,12 @@ internal class RoomSummaryDataSource @Inject constructor(@SessionDatabase privat LivePagedListBuilder(dataSourceFactory, pagedListConfig) ) - return object : UpdatableFilterLivePageResult { + return object : UpdatableLivePageResult { override val livePagedList: LiveData> = mapped - override fun updateQuery(queryParams: RoomSummaryQueryParams) { + override fun updateQuery(builder: (RoomSummaryQueryParams) -> RoomSummaryQueryParams) { realmDataSourceFactory.updateQuery { - roomSummariesQuery(it, queryParams) - .sort(RoomSummaryEntityFields.LAST_ACTIVITY_TIME, Sort.DESCENDING) + roomSummariesQuery(it, builder.invoke(queryParams)).process(sortOrder) } } } @@ -170,10 +227,10 @@ internal class RoomSummaryDataSource @Inject constructor(@SessionDatabase privat queryParams.roomCategoryFilter?.let { when (it) { - RoomCategoryFilter.ONLY_DM -> query.equalTo(RoomSummaryEntityFields.IS_DIRECT, true) - RoomCategoryFilter.ONLY_ROOMS -> query.equalTo(RoomSummaryEntityFields.IS_DIRECT, false) + RoomCategoryFilter.ONLY_DM -> query.equalTo(RoomSummaryEntityFields.IS_DIRECT, true) + RoomCategoryFilter.ONLY_ROOMS -> query.equalTo(RoomSummaryEntityFields.IS_DIRECT, false) RoomCategoryFilter.ONLY_WITH_NOTIFICATIONS -> query.greaterThan(RoomSummaryEntityFields.NOTIFICATION_COUNT, 0) - RoomCategoryFilter.ALL -> { + RoomCategoryFilter.ALL -> { // nop } } @@ -189,6 +246,160 @@ internal class RoomSummaryDataSource @Inject constructor(@SessionDatabase privat query.equalTo(RoomSummaryEntityFields.IS_SERVER_NOTICE, sn) } } + + queryParams.excludeType?.forEach { + query.notEqualTo(RoomSummaryEntityFields.ROOM_TYPE, it) + } + queryParams.includeType?.forEach { + query.equalTo(RoomSummaryEntityFields.ROOM_TYPE, it) + } + when (queryParams.roomCategoryFilter) { + RoomCategoryFilter.ONLY_DM -> query.equalTo(RoomSummaryEntityFields.IS_DIRECT, true) + RoomCategoryFilter.ONLY_ROOMS -> query.equalTo(RoomSummaryEntityFields.IS_DIRECT, false) + RoomCategoryFilter.ONLY_WITH_NOTIFICATIONS -> query.greaterThan(RoomSummaryEntityFields.NOTIFICATION_COUNT, 0) + RoomCategoryFilter.ALL -> Unit // nop + } + + // Timber.w("VAL: activeSpaceId : ${queryParams.activeSpaceId}") + when (queryParams.activeSpaceId) { + is ActiveSpaceFilter.ActiveSpace -> { + // It's annoying but for now realm java does not support querying in primitive list :/ + // https://github.com/realm/realm-java/issues/5361 + if (queryParams.activeSpaceId.currentSpaceId == null) { + // orphan rooms + query.isNull(RoomSummaryEntityFields.FLATTEN_PARENT_IDS) + } else { + query.contains(RoomSummaryEntityFields.FLATTEN_PARENT_IDS, queryParams.activeSpaceId.currentSpaceId) + } + } + is ActiveSpaceFilter.ExcludeSpace -> { + query.not().contains(RoomSummaryEntityFields.FLATTEN_PARENT_IDS, queryParams.activeSpaceId.spaceId) + } + else -> { + // nop + } + } + + if (queryParams.activeGroupId != null) { + query.contains(RoomSummaryEntityFields.GROUP_IDS, queryParams.activeGroupId!!) + } return query } + + fun getAllRoomSummaryChildOf(spaceAliasOrId: String, memberShips: List): List { + val space = getSpaceSummary(spaceAliasOrId) ?: return emptyList() + val result = ArrayList() + flattenChild(space, emptyList(), result, memberShips) + return result + } + + fun getAllRoomSummaryChildOfLive(spaceId: String, memberShips: List): LiveData> { + // we want to listen to all spaces in hierarchy and on change compute back all childs + // and switch map to listen those? + val mediatorLiveData = HierarchyLiveDataHelper(spaceId, memberShips, this).liveData() + + return Transformations.switchMap(mediatorLiveData) { allIds -> + monarchy.findAllMappedWithChanges( + { + it.where() + .`in`(RoomSummaryEntityFields.ROOM_ID, allIds.toTypedArray()) + .`in`(RoomSummaryEntityFields.MEMBERSHIP_STR, memberShips.map { it.name }.toTypedArray()) + .equalTo(RoomSummaryEntityFields.IS_DIRECT, false) + }, + { + roomSummaryMapper.map(it) + }) + } + } + + fun getFlattenOrphanRooms(): List { + return getRoomSummaries( + roomSummaryQueryParams { + memberships = Membership.activeMemberships() + excludeType = listOf(RoomType.SPACE) + roomCategoryFilter = RoomCategoryFilter.ONLY_ROOMS + } + ).filter { isOrphan(it) } + } + + fun getFlattenOrphanRoomsLive(): LiveData> { + return Transformations.map( + getRoomSummariesLive(roomSummaryQueryParams { + memberships = Membership.activeMemberships() + excludeType = listOf(RoomType.SPACE) + roomCategoryFilter = RoomCategoryFilter.ONLY_ROOMS + }) + ) { + it.filter { isOrphan(it) } + } + } + + private fun isOrphan(roomSummary: RoomSummary): Boolean { + if (roomSummary.roomType == RoomType.SPACE && roomSummary.membership.isActive()) { + return false + } + // all parents line should be orphan + roomSummary.spaceParents?.forEach { info -> + if (info.roomSummary != null && !info.roomSummary.membership.isLeft()) { + if (!isOrphan(info.roomSummary)) { + return false + } + } + } + + // it may not have a parent relation but could be a child of some other.... + for (spaceSummary in getSpaceSummaries(spaceSummaryQueryParams { memberships = Membership.activeMemberships() })) { + if (spaceSummary.spaceChildren?.any { it.childRoomId == roomSummary.roomId } == true) { + return false + } + } + + return true + } + + fun flattenChild(current: RoomSummary, parenting: List, output: MutableList, memberShips: List) { + current.spaceChildren?.sortedBy { it.order ?: it.name }?.forEach { childInfo -> + if (childInfo.roomType == RoomType.SPACE) { + // Add recursive + if (!parenting.contains(childInfo.childRoomId)) { // avoid cycles! + getSpaceSummary(childInfo.childRoomId)?.let { subSpace -> + if (memberShips.isEmpty() || memberShips.contains(subSpace.membership)) { + flattenChild(subSpace, parenting + listOf(current.roomId), output, memberShips) + } + } + } + } else if (childInfo.isKnown) { + getRoomSummary(childInfo.childRoomId)?.let { + if (memberShips.isEmpty() || memberShips.contains(it.membership)) { + if (!it.isDirect) { + output.add(it) + } + } + } + } + } + } + + fun flattenSubSpace(current: RoomSummary, + parenting: List, + output: MutableList, + memberShips: List, + includeCurrent: Boolean = true) { + if (includeCurrent) { + output.add(current) + } + current.spaceChildren?.sortedBy { it.order ?: it.name }?.forEach { + if (it.roomType == RoomType.SPACE) { + // Add recursive + if (!parenting.contains(it.childRoomId)) { // avoid cycles! + getSpaceSummary(it.childRoomId)?.let { subSpace -> + if (memberShips.isEmpty() || memberShips.contains(subSpace.membership)) { + output.add(subSpace) + flattenSubSpace(subSpace, parenting + listOf(current.roomId), output, memberShips) + } + } + } + } + } + } } diff --git a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/room/summary/RoomSummaryUpdater.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/room/summary/RoomSummaryUpdater.kt index 7913bf71a2..f580a7f354 100644 --- a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/room/summary/RoomSummaryUpdater.kt +++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/room/summary/RoomSummaryUpdater.kt @@ -17,6 +17,7 @@ package org.matrix.android.sdk.internal.session.room.summary import io.realm.Realm +import io.realm.kotlin.createObject import org.matrix.android.sdk.api.extensions.tryOrNull import org.matrix.android.sdk.api.session.events.model.EventType import org.matrix.android.sdk.api.session.events.model.toModel @@ -25,6 +26,8 @@ import org.matrix.android.sdk.api.session.room.model.RoomAliasesContent import org.matrix.android.sdk.api.session.room.model.RoomCanonicalAliasContent import org.matrix.android.sdk.api.session.room.model.RoomNameContent import org.matrix.android.sdk.api.session.room.model.RoomTopicContent +import org.matrix.android.sdk.api.session.room.model.RoomType +import org.matrix.android.sdk.api.session.room.model.create.RoomCreateContent import org.matrix.android.sdk.api.session.room.send.SendState import org.matrix.android.sdk.internal.crypto.EventDecryptor import org.matrix.android.sdk.internal.crypto.MXCRYPTO_ALGORITHM_MEGOLM @@ -34,29 +37,40 @@ import org.matrix.android.sdk.internal.database.mapper.asDomain import org.matrix.android.sdk.internal.database.model.CurrentStateEventEntity import org.matrix.android.sdk.internal.database.model.EventEntity import org.matrix.android.sdk.internal.database.model.EventEntityFields +import org.matrix.android.sdk.internal.database.model.GroupSummaryEntity import org.matrix.android.sdk.internal.database.model.RoomMemberSummaryEntityFields import org.matrix.android.sdk.internal.database.model.RoomSummaryEntity +import org.matrix.android.sdk.internal.database.model.RoomSummaryEntityFields +import org.matrix.android.sdk.internal.database.model.SpaceChildSummaryEntity +import org.matrix.android.sdk.internal.database.model.SpaceParentSummaryEntity import org.matrix.android.sdk.internal.database.model.TimelineEventEntity import org.matrix.android.sdk.internal.database.query.findAllInRoomWithSendStates import org.matrix.android.sdk.internal.database.query.getOrCreate import org.matrix.android.sdk.internal.database.query.getOrNull import org.matrix.android.sdk.internal.database.query.isEventRead +import org.matrix.android.sdk.internal.database.query.where import org.matrix.android.sdk.internal.database.query.whereType import org.matrix.android.sdk.internal.di.UserId +import org.matrix.android.sdk.internal.extensions.clearWith +import org.matrix.android.sdk.internal.query.process import org.matrix.android.sdk.internal.session.room.RoomAvatarResolver import org.matrix.android.sdk.internal.session.room.membership.RoomDisplayNameResolver import org.matrix.android.sdk.internal.session.room.membership.RoomMemberHelper +import org.matrix.android.sdk.internal.session.room.relationship.RoomChildRelationInfo +import org.matrix.android.sdk.internal.session.room.state.StateEventDataSource import org.matrix.android.sdk.internal.session.sync.model.RoomSyncSummary import org.matrix.android.sdk.internal.session.sync.model.RoomSyncUnreadNotifications import timber.log.Timber import javax.inject.Inject +import kotlin.system.measureTimeMillis internal class RoomSummaryUpdater @Inject constructor( @UserId private val userId: String, private val roomDisplayNameResolver: RoomDisplayNameResolver, private val roomAvatarResolver: RoomAvatarResolver, private val eventDecryptor: EventDecryptor, - private val crossSigningService: DefaultCrossSigningService) { + private val crossSigningService: DefaultCrossSigningService, + private val stateEventDataSource: StateEventDataSource) { fun update(realm: Realm, roomId: String, @@ -89,6 +103,11 @@ internal class RoomSummaryUpdater @Inject constructor( val lastTopicEvent = CurrentStateEventEntity.getOrNull(realm, roomId, type = EventType.STATE_ROOM_TOPIC, stateKey = "")?.root val lastCanonicalAliasEvent = CurrentStateEventEntity.getOrNull(realm, roomId, type = EventType.STATE_ROOM_CANONICAL_ALIAS, stateKey = "")?.root val lastAliasesEvent = CurrentStateEventEntity.getOrNull(realm, roomId, type = EventType.STATE_ROOM_ALIASES, stateKey = "")?.root + val roomCreateEvent = CurrentStateEventEntity.getOrNull(realm, roomId, type = EventType.STATE_ROOM_CREATE, stateKey = "")?.root + + val roomType = ContentMapper.map(roomCreateEvent?.content).toModel()?.type + roomSummaryEntity.roomType = roomType + Timber.v("## Space: Updating summary room [$roomId] roomType: [$roomType]") // Don't use current state for this one as we are only interested in having MXCRYPTO_ALGORITHM_MEGOLM event in the room val encryptionEvent = EventEntity.whereType(realm, roomId = roomId, type = EventType.STATE_ROOM_ENCRYPTION) @@ -163,4 +182,233 @@ internal class RoomSummaryUpdater @Inject constructor( roomSummaryEntity.updateHasFailedSending() roomSummaryEntity.latestPreviewableEvent = RoomSummaryEventsHelper.getLatestPreviewableEvent(realm, roomId) } + + /** + * Should be called at the end of the room sync, to check and validate all parent/child relations + */ + fun validateSpaceRelationship(realm: Realm) { + measureTimeMillis { + val lookupMap = realm.where(RoomSummaryEntity::class.java) + .process(RoomSummaryEntityFields.MEMBERSHIP_STR, Membership.activeMemberships()) + .equalTo(RoomSummaryEntityFields.IS_DIRECT, false) + // we order by roomID to be consistent when breaking parent/child cycles + .sort(RoomSummaryEntityFields.ROOM_ID) + .findAll().map { + it.flattenParentIds = null + it to emptyList().toMutableSet() + } + .toMap() + + lookupMap.keys.forEach { lookedUp -> + if (lookedUp.roomType == RoomType.SPACE) { + // get childrens + + lookedUp.children.clearWith { it.deleteFromRealm() } + + RoomChildRelationInfo(realm, lookedUp.roomId).getDirectChildrenDescriptions().forEach { child -> + + lookedUp.children.add( + realm.createObject().apply { + this.childRoomId = child.roomId + this.childSummaryEntity = RoomSummaryEntity.where(realm, child.roomId).findFirst() + this.order = child.order + this.autoJoin = child.autoJoin + this.viaServers.addAll(child.viaServers) + } + ) + + RoomSummaryEntity.where(realm, child.roomId) + .process(RoomSummaryEntityFields.MEMBERSHIP_STR, Membership.activeMemberships()) + .findFirst() + ?.let { childSum -> + lookupMap.entries.firstOrNull { it.key.roomId == lookedUp.roomId }?.let { entry -> + if (entry.value.indexOfFirst { it.roomId == childSum.roomId } == -1) { + // add looked up as a parent + entry.value.add(childSum) + } + } + } + } + } else { + lookedUp.parents.clearWith { it.deleteFromRealm() } + // can we check parent relations here?? + RoomChildRelationInfo(realm, lookedUp.roomId).getParentDescriptions() + .map { parentInfo -> + + lookedUp.parents.add( + realm.createObject().apply { + this.parentRoomId = parentInfo.roomId + this.parentSummaryEntity = RoomSummaryEntity.where(realm, parentInfo.roomId).findFirst() + this.canonical = parentInfo.canonical + this.viaServers.addAll(parentInfo.viaServers) + } + ) + + RoomSummaryEntity.where(realm, parentInfo.roomId) + .process(RoomSummaryEntityFields.MEMBERSHIP_STR, Membership.activeMemberships()) + .findFirst() + ?.let { parentSum -> + if (lookupMap[parentSum]?.indexOfFirst { it.roomId == lookedUp.roomId } == -1) { + // add lookedup as a parent + lookupMap[parentSum]?.add(lookedUp) + } + } + } + } + } + + // Simple algorithm to break cycles + // Need more work to decide how to break, probably need to be as consistent as possible + // and also find best way to root the tree + + val graph = Graph() + lookupMap + // focus only on joined spaces, as room are just leaf + .filter { it.key.roomType == RoomType.SPACE && it.key.membership == Membership.JOIN } + .forEach { (sum, children) -> + graph.getOrCreateNode(sum.roomId) + children.forEach { + graph.addEdge(it.roomId, sum.roomId) + } + } + + val backEdges = graph.findBackwardEdges() + Timber.v("## SPACES: Cycle detected = ${backEdges.isNotEmpty()}") + + // break cycles + backEdges.forEach { edge -> + lookupMap.entries.find { it.key.roomId == edge.source.name }?.let { + it.value.removeAll { it.roomId == edge.destination.name } + } + } + + val acyclicGraph = graph.withoutEdges(backEdges) +// Timber.v("## SPACES: acyclicGraph $acyclicGraph") + val flattenSpaceParents = acyclicGraph.flattenDestination().map { + it.key.name to it.value.map { it.name } + }.toMap() +// Timber.v("## SPACES: flattenSpaceParents ${flattenSpaceParents.map { it.key.name to it.value.map { it.name } }.joinToString("\n") { +// it.first + ": [" + it.second.joinToString(",") + "]" +// }}") + +// Timber.v("## SPACES: lookup map ${lookupMap.map { it.key.name to it.value.map { it.name } }.toMap()}") + + lookupMap.entries + .filter { it.key.roomType == RoomType.SPACE && it.key.membership == Membership.JOIN } + .forEach { entry -> + val parent = RoomSummaryEntity.where(realm, entry.key.roomId).findFirst() + if (parent != null) { +// Timber.v("## SPACES: check hierarchy of ${parent.name} id ${parent.roomId}") +// Timber.v("## SPACES: flat known parents of ${parent.name} are ${flattenSpaceParents[parent.roomId]}") + val flattenParentsIds = (flattenSpaceParents[parent.roomId] ?: emptyList()) + listOf(parent.roomId) +// Timber.v("## SPACES: flatten known parents of children of ${parent.name} are ${flattenParentsIds}") + entry.value.forEach { child -> + RoomSummaryEntity.where(realm, child.roomId).findFirst()?.let { childSum -> + +// Timber.w("## SPACES: ${childSum.name} is ${childSum.roomId} fc: ${childSum.flattenParentIds}") +// var allParents = childSum.flattenParentIds ?: "" + if (childSum.flattenParentIds == null) childSum.flattenParentIds = "" + flattenParentsIds.forEach { + if (childSum.flattenParentIds?.contains(it) != true) { + childSum.flattenParentIds += "|$it" + } + } +// childSum.flattenParentIds = "$allParents|" + +// Timber.v("## SPACES: flatten of ${childSum.name} is ${childSum.flattenParentIds}") + } + } + } + } + + // we need also to filter DMs... + // it's more annoying as based on if the other members belong the space or not + RoomSummaryEntity.where(realm) + .equalTo(RoomSummaryEntityFields.IS_DIRECT, true) + .process(RoomSummaryEntityFields.MEMBERSHIP_STR, Membership.activeMemberships()) + .findAll() + .forEach { dmRoom -> + val relatedSpaces = lookupMap.keys + .filter { it.roomType == RoomType.SPACE } + .filter { + dmRoom.otherMemberIds.toList().intersect(it.otherMemberIds.toList()).isNotEmpty() + } + .map { it.roomId } + .distinct() + val flattenRelated = mutableListOf().apply { + addAll(relatedSpaces) + relatedSpaces.map { flattenSpaceParents[it] }.forEach { + if (it != null) addAll(it) + } + }.distinct() + if (flattenRelated.isEmpty()) { + dmRoom.flattenParentIds = null + } else { + dmRoom.flattenParentIds = "|${flattenRelated.joinToString("|")}|" + } +// Timber.v("## SPACES: flatten of ${dmRoom.otherMemberIds.joinToString(",")} is ${dmRoom.flattenParentIds}") + } + + // Maybe a good place to count the number of notifications for spaces? + + realm.where(RoomSummaryEntity::class.java) + .process(RoomSummaryEntityFields.MEMBERSHIP_STR, Membership.activeMemberships()) + .equalTo(RoomSummaryEntityFields.ROOM_TYPE, RoomType.SPACE) + .findAll().forEach { space -> + // get all children + var highlightCount = 0 + var notificationCount = 0 + realm.where(RoomSummaryEntity::class.java) + .process(RoomSummaryEntityFields.MEMBERSHIP_STR, listOf(Membership.JOIN)) + .notEqualTo(RoomSummaryEntityFields.ROOM_TYPE, RoomType.SPACE) + .contains(RoomSummaryEntityFields.FLATTEN_PARENT_IDS, space.roomId) + .findAll().forEach { + highlightCount += it.highlightCount + notificationCount += it.notificationCount + } + + space.highlightCount = highlightCount + space.notificationCount = notificationCount + } + // xxx invites?? + + // LEGACY GROUPS + // lets mark rooms that belongs to groups + val existingGroups = GroupSummaryEntity.where(realm).findAll() + + // For rooms + realm.where(RoomSummaryEntity::class.java) + .process(RoomSummaryEntityFields.MEMBERSHIP_STR, Membership.activeMemberships()) + .equalTo(RoomSummaryEntityFields.IS_DIRECT, false) + .findAll().forEach { room -> + val belongsTo = existingGroups.filter { it.roomIds.contains(room.roomId) } + room.groupIds = if (belongsTo.isEmpty()) { + null + } else { + "|${belongsTo.joinToString("|")}|" + } + } + + // For DMS + realm.where(RoomSummaryEntity::class.java) + .process(RoomSummaryEntityFields.MEMBERSHIP_STR, Membership.activeMemberships()) + .equalTo(RoomSummaryEntityFields.IS_DIRECT, true) + .findAll().forEach { room -> + val belongsTo = existingGroups.filter { + it.userIds.intersect(room.otherMemberIds).isNotEmpty() + } + room.groupIds = if (belongsTo.isEmpty()) { + null + } else { + "|${belongsTo.joinToString("|")}|" + } + } + }.also { + Timber.v("## SPACES: Finish checking room hierarchy in $it ms") + } + } + +// private fun isValidCanonical() : Boolean { +// +// } } diff --git a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/room/timeline/TokenChunkEventPersistor.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/room/timeline/TokenChunkEventPersistor.kt index c38dcd00a7..a7cba2fe99 100644 --- a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/room/timeline/TokenChunkEventPersistor.kt +++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/room/timeline/TokenChunkEventPersistor.kt @@ -149,7 +149,7 @@ internal class TokenChunkEventPersistor @Inject constructor(@SessionDatabase pri } ?: ChunkEntity.create(realm, prevToken, nextToken) - if (receivedChunk.events.isEmpty() && !receivedChunk.hasMore()) { + if (receivedChunk.events.isNullOrEmpty() && !receivedChunk.hasMore()) { handleReachEnd(realm, roomId, direction, currentChunk) } else { handlePagination(realm, roomId, direction, receivedChunk, currentChunk) diff --git a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/space/DefaultSpace.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/space/DefaultSpace.kt new file mode 100644 index 0000000000..93cb9d9d34 --- /dev/null +++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/space/DefaultSpace.kt @@ -0,0 +1,119 @@ +/* + * Copyright 2020 The Matrix.org Foundation C.I.C. + * + * 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 org.matrix.android.sdk.internal.session.space + +import org.matrix.android.sdk.api.query.QueryStringValue +import org.matrix.android.sdk.api.session.events.model.EventType +import org.matrix.android.sdk.api.session.events.model.toContent +import org.matrix.android.sdk.api.session.events.model.toModel +import org.matrix.android.sdk.api.session.room.Room +import org.matrix.android.sdk.api.session.room.model.RoomSummary +import org.matrix.android.sdk.api.session.space.Space +import org.matrix.android.sdk.api.session.space.model.SpaceChildContent +import org.matrix.android.sdk.internal.session.room.summary.RoomSummaryDataSource + +internal class DefaultSpace( + private val room: Room, + private val spaceSummaryDataSource: RoomSummaryDataSource +) : Space { + + override fun asRoom(): Room { + return room + } + + override val spaceId = room.roomId + + override suspend fun leave(reason: String?) { + return room.leave(reason) + } + + override fun spaceSummary(): RoomSummary? { + return spaceSummaryDataSource.getSpaceSummary(room.roomId) + } + + override suspend fun addChildren(roomId: String, + viaServers: List, + order: String?, + autoJoin: Boolean, + suggested: Boolean?) { + room.sendStateEvent( + eventType = EventType.STATE_SPACE_CHILD, + stateKey = roomId, + body = SpaceChildContent( + via = viaServers, + autoJoin = autoJoin, + order = order, + suggested = suggested + ).toContent() + ) + } + + override suspend fun removeChildren(roomId: String) { + val existing = room.getStateEvents(setOf(EventType.STATE_SPACE_CHILD), QueryStringValue.Equals(roomId)) + .firstOrNull() + ?.content.toModel() + ?: // should we throw here? + return + + // edit state event and set via to null + room.sendStateEvent( + eventType = EventType.STATE_SPACE_CHILD, + stateKey = roomId, + body = SpaceChildContent( + order = existing.order, + via = null, + autoJoin = existing.autoJoin + ).toContent() + ) + } + + override suspend fun setChildrenOrder(roomId: String, order: String?) { + val existing = room.getStateEvents(setOf(EventType.STATE_SPACE_CHILD), QueryStringValue.Equals(roomId)) + .firstOrNull() + ?.content.toModel() + ?: throw IllegalArgumentException("$roomId is not a child of this space") + + // edit state event and set via to null + room.sendStateEvent( + eventType = EventType.STATE_SPACE_CHILD, + stateKey = roomId, + body = SpaceChildContent( + order = order, + via = existing.via, + autoJoin = existing.autoJoin + ).toContent() + ) + } + + override suspend fun setChildrenAutoJoin(roomId: String, autoJoin: Boolean) { + val existing = room.getStateEvents(setOf(EventType.STATE_SPACE_CHILD), QueryStringValue.Equals(roomId)) + .firstOrNull() + ?.content.toModel() + ?: throw IllegalArgumentException("$roomId is not a child of this space") + + // edit state event and set via to null + room.sendStateEvent( + eventType = EventType.STATE_SPACE_CHILD, + stateKey = roomId, + body = SpaceChildContent( + order = existing.order, + via = existing.via, + autoJoin = autoJoin + ).toContent() + ) + } +} diff --git a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/space/DefaultSpaceService.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/space/DefaultSpaceService.kt new file mode 100644 index 0000000000..8fdc563edb --- /dev/null +++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/space/DefaultSpaceService.kt @@ -0,0 +1,199 @@ +/* + * Copyright 2020 The Matrix.org Foundation C.I.C. + * + * 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 org.matrix.android.sdk.internal.session.space + +import android.net.Uri +import androidx.lifecycle.LiveData +import org.matrix.android.sdk.api.query.QueryStringValue +import org.matrix.android.sdk.api.session.events.model.EventType +import org.matrix.android.sdk.api.session.events.model.toContent +import org.matrix.android.sdk.api.session.events.model.toModel +import org.matrix.android.sdk.api.session.room.model.GuestAccess +import org.matrix.android.sdk.api.session.room.model.Membership +import org.matrix.android.sdk.api.session.room.model.PowerLevelsContent +import org.matrix.android.sdk.api.session.room.model.RoomDirectoryVisibility +import org.matrix.android.sdk.api.session.room.model.RoomHistoryVisibility +import org.matrix.android.sdk.api.session.room.model.RoomSummary +import org.matrix.android.sdk.api.session.room.model.SpaceChildInfo +import org.matrix.android.sdk.api.session.room.model.create.CreateRoomPreset +import org.matrix.android.sdk.api.session.room.powerlevels.PowerLevelsHelper +import org.matrix.android.sdk.api.session.space.CreateSpaceParams +import org.matrix.android.sdk.api.session.space.JoinSpaceResult +import org.matrix.android.sdk.api.session.space.Space +import org.matrix.android.sdk.api.session.space.SpaceService +import org.matrix.android.sdk.api.session.space.SpaceSummaryQueryParams +import org.matrix.android.sdk.api.session.space.model.SpaceChildContent +import org.matrix.android.sdk.api.session.space.model.SpaceParentContent +import org.matrix.android.sdk.internal.di.UserId +import org.matrix.android.sdk.internal.session.room.RoomGetter +import org.matrix.android.sdk.internal.session.room.SpaceGetter +import org.matrix.android.sdk.internal.session.room.create.CreateRoomTask +import org.matrix.android.sdk.internal.session.room.membership.leaving.LeaveRoomTask +import org.matrix.android.sdk.internal.session.room.state.StateEventDataSource +import org.matrix.android.sdk.internal.session.room.summary.RoomSummaryDataSource +import org.matrix.android.sdk.internal.session.space.peeking.PeekSpaceTask +import org.matrix.android.sdk.internal.session.space.peeking.SpacePeekResult +import javax.inject.Inject + +internal class DefaultSpaceService @Inject constructor( + @UserId private val userId: String, + private val createRoomTask: CreateRoomTask, + private val joinSpaceTask: JoinSpaceTask, + private val spaceGetter: SpaceGetter, + private val roomGetter: RoomGetter, + private val roomSummaryDataSource: RoomSummaryDataSource, + private val stateEventDataSource: StateEventDataSource, + private val peekSpaceTask: PeekSpaceTask, + private val resolveSpaceInfoTask: ResolveSpaceInfoTask, + private val leaveRoomTask: LeaveRoomTask +) : SpaceService { + + override suspend fun createSpace(params: CreateSpaceParams): String { + return createRoomTask.executeRetry(params, 3) + } + + override suspend fun createSpace(name: String, topic: String?, avatarUri: Uri?, isPublic: Boolean): String { + return createSpace(CreateSpaceParams().apply { + this.name = name + this.topic = topic + this.avatarUri = avatarUri + if (isPublic) { + this.powerLevelContentOverride = (powerLevelContentOverride ?: PowerLevelsContent()).copy( + invite = 0 + ) + this.preset = CreateRoomPreset.PRESET_PUBLIC_CHAT + this.historyVisibility = RoomHistoryVisibility.WORLD_READABLE + this.guestAccess = GuestAccess.CanJoin + } else { + this.preset = CreateRoomPreset.PRESET_PRIVATE_CHAT + visibility = RoomDirectoryVisibility.PRIVATE + enableEncryption() + } + }) + } + + override fun getSpace(spaceId: String): Space? { + return spaceGetter.get(spaceId) + } + + override fun getSpaceSummariesLive(queryParams: SpaceSummaryQueryParams): LiveData> { + return roomSummaryDataSource.getSpaceSummariesLive(queryParams) + } + + override fun getSpaceSummaries(spaceSummaryQueryParams: SpaceSummaryQueryParams): List { + return roomSummaryDataSource.getSpaceSummaries(spaceSummaryQueryParams) + } + + override fun getRootSpaceSummaries(): List { + return roomSummaryDataSource.getRootSpaceSummaries() + } + + override suspend fun peekSpace(spaceId: String): SpacePeekResult { + return peekSpaceTask.execute(PeekSpaceTask.Params(spaceId)) + } + + override suspend fun querySpaceChildren(spaceId: String, + suggestedOnly: Boolean?, + autoJoinedOnly: Boolean?): Pair> { + return resolveSpaceInfoTask.execute(ResolveSpaceInfoTask.Params.withId(spaceId, suggestedOnly, autoJoinedOnly)).let { response -> + val spaceDesc = response.rooms?.firstOrNull { it.roomId == spaceId } + Pair( + first = RoomSummary( + roomId = spaceDesc?.roomId ?: spaceId, + roomType = spaceDesc?.roomType, + name = spaceDesc?.name ?: "", + displayName = spaceDesc?.name ?: "", + topic = spaceDesc?.topic ?: "", + joinedMembersCount = spaceDesc?.numJoinedMembers, + avatarUrl = spaceDesc?.avatarUrl ?: "", + encryptionEventTs = null, + typingUsers = emptyList(), + isEncrypted = false, + flattenParentIds = emptyList() + ), + second = response.rooms + ?.filter { it.roomId != spaceId } + ?.map { childSummary -> + val childStateEv = response.events + ?.firstOrNull { it.stateKey == childSummary.roomId && it.type == EventType.STATE_SPACE_CHILD } + val childStateEvContent = childStateEv?.content.toModel() + SpaceChildInfo( + childRoomId = childSummary.roomId, + isKnown = true, + roomType = childSummary.roomType, + name = childSummary.name, + topic = childSummary.topic, + avatarUrl = childSummary.avatarUrl, + order = childStateEvContent?.order, + autoJoin = childStateEvContent?.autoJoin ?: false, + viaServers = childStateEvContent?.via ?: emptyList(), + activeMemberCount = childSummary.numJoinedMembers, + parentRoomId = childStateEv?.roomId + ) + }.orEmpty() + ) + } + } + + override suspend fun joinSpace(spaceIdOrAlias: String, + reason: String?, + viaServers: List): JoinSpaceResult { + return joinSpaceTask.execute(JoinSpaceTask.Params(spaceIdOrAlias, reason, viaServers)) + } + + override suspend fun rejectInvite(spaceId: String, reason: String?) { + leaveRoomTask.execute(LeaveRoomTask.Params(spaceId, reason)) + } + +// override fun getSpaceParentsOfRoom(roomId: String): List { +// return spaceSummaryDataSource.getParentsOfRoom(roomId) +// } + + override suspend fun setSpaceParent(childRoomId: String, parentSpaceId: String, canonical: Boolean, viaServers: List) { + // Should we perform some validation here?, + // and if client want to bypass, it could use sendStateEvent directly? + if (canonical) { + // check that we can send m.child in the parent room + if (roomSummaryDataSource.getRoomSummary(parentSpaceId)?.membership != Membership.JOIN) { + throw UnsupportedOperationException("Cannot add canonical child if not member of parent") + } + val powerLevelsEvent = stateEventDataSource.getStateEvent( + roomId = parentSpaceId, + eventType = EventType.STATE_ROOM_POWER_LEVELS, + stateKey = QueryStringValue.NoCondition + ) + val powerLevelsContent = powerLevelsEvent?.content?.toModel() + ?: throw UnsupportedOperationException("Cannot add canonical child, missing powerlevel") + val powerLevelsHelper = PowerLevelsHelper(powerLevelsContent) + if (!powerLevelsHelper.isUserAllowedToSend(userId, true, EventType.STATE_SPACE_CHILD)) { + throw UnsupportedOperationException("Cannot add canonical child, not enough power level") + } + } + + val room = roomGetter.getRoom(childRoomId) + ?: throw IllegalArgumentException("Unknown Room $childRoomId") + + room.sendStateEvent( + eventType = EventType.STATE_SPACE_PARENT, + stateKey = parentSpaceId, + body = SpaceParentContent( + via = viaServers, + canonical = canonical + ).toContent() + ) + } +} diff --git a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/space/JoinSpaceTask.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/space/JoinSpaceTask.kt new file mode 100644 index 0000000000..5e1b829249 --- /dev/null +++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/space/JoinSpaceTask.kt @@ -0,0 +1,136 @@ +/* + * Copyright 2020 The Matrix.org Foundation C.I.C. + * + * 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 org.matrix.android.sdk.internal.session.space + +import io.realm.RealmConfiguration +import kotlinx.coroutines.TimeoutCancellationException +import org.matrix.android.sdk.api.session.room.model.Membership +import org.matrix.android.sdk.api.session.room.model.RoomType +import org.matrix.android.sdk.api.session.space.JoinSpaceResult +import org.matrix.android.sdk.internal.database.awaitNotEmptyResult +import org.matrix.android.sdk.internal.database.model.RoomSummaryEntity +import org.matrix.android.sdk.internal.database.model.RoomSummaryEntityFields +import org.matrix.android.sdk.internal.di.SessionDatabase +import org.matrix.android.sdk.internal.session.room.membership.joining.JoinRoomTask +import org.matrix.android.sdk.internal.session.room.summary.RoomSummaryDataSource +import org.matrix.android.sdk.internal.task.Task +import timber.log.Timber +import java.util.concurrent.TimeUnit +import javax.inject.Inject + +internal interface JoinSpaceTask : Task { + data class Params( + val roomIdOrAlias: String, + val reason: String?, + val viaServers: List = emptyList() + ) +} + +internal class DefaultJoinSpaceTask @Inject constructor( + private val joinRoomTask: JoinRoomTask, + @SessionDatabase + private val realmConfiguration: RealmConfiguration, + private val roomSummaryDataSource: RoomSummaryDataSource +) : JoinSpaceTask { + + override suspend fun execute(params: JoinSpaceTask.Params): JoinSpaceResult { + Timber.v("## Space: > Joining root space ${params.roomIdOrAlias} ...") + try { + joinRoomTask.execute(JoinRoomTask.Params( + params.roomIdOrAlias, + params.reason, + params.viaServers + )) + } catch (failure: Throwable) { + return JoinSpaceResult.Fail(failure) + } + Timber.v("## Space: < Joining root space done for ${params.roomIdOrAlias}") + // we want to wait for sync result to check for auto join rooms + + Timber.v("## Space: > Wait for post joined sync ${params.roomIdOrAlias} ...") + try { + awaitNotEmptyResult(realmConfiguration, TimeUnit.MINUTES.toMillis(2L)) { realm -> + realm.where(RoomSummaryEntity::class.java) + .apply { + if (params.roomIdOrAlias.startsWith("!")) { + equalTo(RoomSummaryEntityFields.ROOM_ID, params.roomIdOrAlias) + } else { + equalTo(RoomSummaryEntityFields.CANONICAL_ALIAS, params.roomIdOrAlias) + } + } + .equalTo(RoomSummaryEntityFields.MEMBERSHIP_STR, Membership.JOIN.name) + } + } catch (exception: TimeoutCancellationException) { + Timber.w("## Space: > Error created with timeout") + return JoinSpaceResult.PartialSuccess(emptyMap()) + } + + val errors = mutableMapOf() + Timber.v("## Space: > Sync done ...") + // after that i should have the children (? do I need to paginate to get state) + val summary = roomSummaryDataSource.getSpaceSummary(params.roomIdOrAlias) + Timber.v("## Space: Found space summary Name:[${summary?.name}] children: ${summary?.spaceChildren?.size}") + summary?.spaceChildren?.forEach { +// val childRoomSummary = it.roomSummary ?: return@forEach + Timber.v("## Space: Processing child :[${it.childRoomId}] autoJoin:${it.autoJoin}") + if (it.autoJoin) { + // I should try to join as well + if (it.roomType == RoomType.SPACE) { + // recursively join auto-joined child of this space? + when (val subspaceJoinResult = execute(JoinSpaceTask.Params(it.childRoomId, null, it.viaServers))) { + JoinSpaceResult.Success -> { + // nop + } + is JoinSpaceResult.Fail -> { + errors[it.childRoomId] = subspaceJoinResult.error + } + is JoinSpaceResult.PartialSuccess -> { + errors.putAll(subspaceJoinResult.failedRooms) + } + } + } else { + try { + Timber.v("## Space: Joining room child ${it.childRoomId}") + joinRoomTask.execute(JoinRoomTask.Params( + roomIdOrAlias = it.childRoomId, + reason = "Auto-join parent space", + viaServers = it.viaServers + )) + } catch (failure: Throwable) { + errors[it.childRoomId] = failure + Timber.e("## Space: Failed to join room child ${it.childRoomId}") + } + } + } + } + + return if (errors.isEmpty()) { + JoinSpaceResult.Success + } else { + JoinSpaceResult.PartialSuccess(errors) + } + } +} + +// try { +// awaitNotEmptyResult(realmConfiguration, TimeUnit.MINUTES.toMillis(1L)) { realm -> +// realm.where(RoomEntity::class.java) +// .equalTo(RoomEntityFields.ROOM_ID, roomId) +// } +// } catch (exception: TimeoutCancellationException) { +// throw CreateRoomFailure.CreatedWithTimeout +// } diff --git a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/space/ResolveSpaceInfoTask.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/space/ResolveSpaceInfoTask.kt new file mode 100644 index 0000000000..d2be49b70b --- /dev/null +++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/space/ResolveSpaceInfoTask.kt @@ -0,0 +1,63 @@ +/* + * Copyright 2020 The Matrix.org Foundation C.I.C. + * + * 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 org.matrix.android.sdk.internal.session.space + +import org.matrix.android.sdk.internal.network.GlobalErrorReceiver +import org.matrix.android.sdk.internal.network.executeRequest +import org.matrix.android.sdk.internal.task.Task +import javax.inject.Inject + +internal interface ResolveSpaceInfoTask : Task { + data class Params( + val spaceId: String, + val maxRoomPerSpace: Int?, + val limit: Int, + val batchToken: String?, + val suggestedOnly: Boolean?, + val autoJoinOnly: Boolean? + ) { + companion object { + fun withId(spaceId: String, suggestedOnly: Boolean?, autoJoinOnly: Boolean?) = + Params( + spaceId = spaceId, + maxRoomPerSpace = 10, + limit = 20, + batchToken = null, + suggestedOnly = suggestedOnly, + autoJoinOnly = autoJoinOnly + ) + } + } +} + +internal class DefaultResolveSpaceInfoTask @Inject constructor( + private val spaceApi: SpaceApi, + private val globalErrorReceiver: GlobalErrorReceiver +) : ResolveSpaceInfoTask { + override suspend fun execute(params: ResolveSpaceInfoTask.Params): SpacesResponse { + val body = SpaceSummaryParams( + maxRoomPerSpace = params.maxRoomPerSpace, + limit = params.limit, + batch = params.batchToken ?: "", + autoJoinedOnly = params.autoJoinOnly, + suggestedOnly = params.suggestedOnly + ) + return executeRequest(globalErrorReceiver) { + spaceApi.getSpaces(params.spaceId, body) + } + } +} diff --git a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/space/SpaceApi.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/space/SpaceApi.kt new file mode 100644 index 0000000000..0fcc95fdb3 --- /dev/null +++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/space/SpaceApi.kt @@ -0,0 +1,43 @@ +/* + * Copyright 2020 The Matrix.org Foundation C.I.C. + * + * 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 org.matrix.android.sdk.internal.session.space + +import org.matrix.android.sdk.internal.network.NetworkConstants +import retrofit2.http.Body +import retrofit2.http.POST +import retrofit2.http.Path + +internal interface SpaceApi { + + /** + * + * POST /_matrix/client/r0/rooms/{roomID}/spaces + * { + * "max_rooms_per_space": 5, // The maximum number of rooms/subspaces to return for a given space, if negative unbounded. default: -1. + * "auto_join_only": true, // If true, only return m.space.child events with auto_join:true, default: false, which returns all events. + * "limit": 100, // The maximum number of rooms/subspaces to return, server can override this, default: 100. + * "batch": "opaque_string" // A token to use if this is a subsequent HTTP hit, default: "". + * } + * + * Ref: + * - MSC 2946 https://github.com/matrix-org/matrix-doc/blob/kegan/spaces-summary/proposals/2946-spaces-summary.md + * - https://hackmd.io/fNYh4tjUT5mQfR1uuRzWDA + */ + @POST(NetworkConstants.URI_API_PREFIX_PATH_UNSTABLE + "org.matrix.msc2946/rooms/{roomId}/spaces") + suspend fun getSpaces(@Path("roomId") spaceId: String, + @Body params: SpaceSummaryParams): SpacesResponse +} diff --git a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/space/SpaceChildSummaryResponse.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/space/SpaceChildSummaryResponse.kt new file mode 100644 index 0000000000..5021ff638f --- /dev/null +++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/space/SpaceChildSummaryResponse.kt @@ -0,0 +1,96 @@ +/* + * Copyright 2020 The Matrix.org Foundation C.I.C. + * + * 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 org.matrix.android.sdk.internal.session.space + +import com.squareup.moshi.Json +import com.squareup.moshi.JsonClass + +@JsonClass(generateAdapter = true) +internal data class SpaceChildSummaryResponse( + /** + * The total number of state events which point to or from this room (inbound/outbound edges). + * This includes all m.space.child events in the room, in addition to m.room.parent events which point to this room as a parent. + */ + @Json(name = "num_refs") val numRefs: Int? = null, + + /** + * The room type, which is m.space for subspaces. + * It can be omitted if there is no room type in which case it should be interpreted as a normal room. + */ + @Json(name = "room_type") val roomType: String? = null, + + /** + * Aliases of the room. May be empty. + */ + @Json(name = "aliases") + val aliases: List? = null, + + /** + * The canonical alias of the room, if any. + */ + @Json(name = "canonical_alias") + val canonicalAlias: String? = null, + + /** + * The name of the room, if any. + */ + @Json(name = "name") + val name: String? = null, + + /** + * Required. The number of members joined to the room. + */ + @Json(name = "num_joined_members") + val numJoinedMembers: Int = 0, + + /** + * Required. The ID of the room. + */ + @Json(name = "room_id") + val roomId: String, + + /** + * The topic of the room, if any. + */ + @Json(name = "topic") + val topic: String? = null, + + /** + * Required. Whether the room may be viewed by guest users without joining. + */ + @Json(name = "world_readable") + val worldReadable: Boolean = false, + + /** + * Required. Whether guest users may join the room and participate in it. If they can, + * they will be subject to ordinary power level rules like any other user. + */ + @Json(name = "guest_can_join") + val guestCanJoin: Boolean = false, + + /** + * The URL for the room's avatar, if one is set. + */ + @Json(name = "avatar_url") + val avatarUrl: String? = null, + + /** + * Undocumented item + */ + @Json(name = "m.federate") + val isFederated: Boolean = false +) diff --git a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/space/SpaceModule.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/space/SpaceModule.kt new file mode 100644 index 0000000000..87425f4af2 --- /dev/null +++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/space/SpaceModule.kt @@ -0,0 +1,53 @@ +/* + * Copyright 2020 The Matrix.org Foundation C.I.C. + * + * 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 org.matrix.android.sdk.internal.session.space + +import dagger.Binds +import dagger.Module +import dagger.Provides +import org.matrix.android.sdk.internal.session.SessionScope +import org.matrix.android.sdk.internal.session.room.DefaultSpaceGetter +import org.matrix.android.sdk.internal.session.room.SpaceGetter +import org.matrix.android.sdk.internal.session.space.peeking.DefaultPeekSpaceTask +import org.matrix.android.sdk.internal.session.space.peeking.PeekSpaceTask +import retrofit2.Retrofit + +@Module +internal abstract class SpaceModule { + + @Module + companion object { + @Provides + @JvmStatic + @SessionScope + fun providesSpacesAPI(retrofit: Retrofit): SpaceApi { + return retrofit.create(SpaceApi::class.java) + } + } + + @Binds + abstract fun bindResolveSpaceTask(task: DefaultResolveSpaceInfoTask): ResolveSpaceInfoTask + + @Binds + abstract fun bindPeekSpaceTask(task: DefaultPeekSpaceTask): PeekSpaceTask + + @Binds + abstract fun bindJoinSpaceTask(task: DefaultJoinSpaceTask): JoinSpaceTask + + @Binds + abstract fun bindSpaceGetter(getter: DefaultSpaceGetter): SpaceGetter +} diff --git a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/space/SpaceSummaryParams.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/space/SpaceSummaryParams.kt new file mode 100644 index 0000000000..013db1c286 --- /dev/null +++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/space/SpaceSummaryParams.kt @@ -0,0 +1,34 @@ +/* + * Copyright 2020 The Matrix.org Foundation C.I.C. + * + * 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 org.matrix.android.sdk.internal.session.space + +import com.squareup.moshi.Json +import com.squareup.moshi.JsonClass + +@JsonClass(generateAdapter = true) +internal data class SpaceSummaryParams( + /** The maximum number of rooms/subspaces to return for a given space, if negative unbounded. default: -1 */ + @Json(name = "max_rooms_per_space") val maxRoomPerSpace: Int?, + /** The maximum number of rooms/subspaces to return, server can override this, default: 100 */ + @Json(name = "limit") val limit: Int?, + /** A token to use if this is a subsequent HTTP hit, default: "". */ + @Json(name = "batch") val batch: String = "", + /** whether we should only return children with the "suggested" flag set. */ + @Json(name = "suggested_only") val suggestedOnly: Boolean?, + /** whether we should only return children with the "suggested" flag set. */ + @Json(name = "auto_join_only") val autoJoinedOnly: Boolean? +) diff --git a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/space/SpacesResponse.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/space/SpacesResponse.kt new file mode 100644 index 0000000000..20d63c8814 --- /dev/null +++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/space/SpacesResponse.kt @@ -0,0 +1,31 @@ +/* + * Copyright 2020 The Matrix.org Foundation C.I.C. + * + * 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 org.matrix.android.sdk.internal.session.space + +import com.squareup.moshi.Json +import com.squareup.moshi.JsonClass +import org.matrix.android.sdk.api.session.events.model.Event + +@JsonClass(generateAdapter = true) +internal data class SpacesResponse( + /** Its presence indicates that there are more results to return. */ + @Json(name = "next_batch") val nextBatch: String? = null, + /** Rooms information like name/avatar/type ... */ + @Json(name = "rooms") val rooms: List? = null, + /** These are the edges of the graph. The objects in the array are complete (or stripped?) m.room.parent or m.space.child events. */ + @Json(name = "events") val events: List? = null +) diff --git a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/space/peeking/PeekSpaceTask.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/space/peeking/PeekSpaceTask.kt new file mode 100644 index 0000000000..f6b156a6fb --- /dev/null +++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/space/peeking/PeekSpaceTask.kt @@ -0,0 +1,143 @@ +/* + * Copyright 2020 The Matrix.org Foundation C.I.C. + * + * 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 org.matrix.android.sdk.internal.session.space.peeking + +import org.matrix.android.sdk.api.session.events.model.Event +import org.matrix.android.sdk.api.session.events.model.EventType +import org.matrix.android.sdk.api.session.events.model.toModel +import org.matrix.android.sdk.api.session.room.model.RoomType +import org.matrix.android.sdk.api.session.room.model.create.RoomCreateContent +import org.matrix.android.sdk.api.session.room.peeking.PeekResult +import org.matrix.android.sdk.api.session.space.model.SpaceChildContent +import org.matrix.android.sdk.internal.session.room.peeking.PeekRoomTask +import org.matrix.android.sdk.internal.session.room.peeking.ResolveRoomStateTask +import org.matrix.android.sdk.internal.task.Task +import timber.log.Timber +import javax.inject.Inject + +internal interface PeekSpaceTask : Task { + data class Params( + val roomIdOrAlias: String, + // A depth limit as a simple protection against cycles + val maxDepth: Int = 4 + ) +} + +internal class DefaultPeekSpaceTask @Inject constructor( + private val peekRoomTask: PeekRoomTask, + private val resolveRoomStateTask: ResolveRoomStateTask +) : PeekSpaceTask { + + override suspend fun execute(params: PeekSpaceTask.Params): SpacePeekResult { + val peekResult = peekRoomTask.execute(PeekRoomTask.Params(params.roomIdOrAlias)) + val roomResult = peekResult as? PeekResult.Success ?: return SpacePeekResult.FailedToResolve(params.roomIdOrAlias, peekResult) + + // check the room type + // kind of duplicate cause we already did it in Peek? could we pass on the result?? + val stateEvents = try { + resolveRoomStateTask.execute(ResolveRoomStateTask.Params(roomResult.roomId)) + } catch (failure: Throwable) { + return SpacePeekResult.FailedToResolve(params.roomIdOrAlias, peekResult) + } + val isSpace = stateEvents + .lastOrNull { it.type == EventType.STATE_ROOM_CREATE && it.stateKey == "" } + ?.content + ?.toModel() + ?.type == RoomType.SPACE + + if (!isSpace) return SpacePeekResult.NotSpaceType(params.roomIdOrAlias) + + val children = peekChildren(stateEvents, 0, params.maxDepth) + + return SpacePeekResult.Success( + SpacePeekSummary( + params.roomIdOrAlias, + peekResult, + children + ) + ) + } + + private suspend fun peekChildren(stateEvents: List, depth: Int, maxDepth: Int): List { + if (depth >= maxDepth) return emptyList() + val childRoomsIds = stateEvents + .filter { + it.type == EventType.STATE_SPACE_CHILD && !it.stateKey.isNullOrEmpty() + // Children where via is not present are ignored. + && it.content?.toModel()?.via != null + } + .map { it.stateKey to it.content?.toModel() } + + Timber.v("## SPACE_PEEK: found ${childRoomsIds.size} present children") + + val spaceChildResults = mutableListOf() + childRoomsIds.forEach { entry -> + + Timber.v("## SPACE_PEEK: peeking child $entry") + // peek each child + val childId = entry.first ?: return@forEach + try { + val childPeek = peekRoomTask.execute(PeekRoomTask.Params(childId)) + + val childStateEvents = resolveRoomStateTask.execute(ResolveRoomStateTask.Params(childId)) + val createContent = childStateEvents + .lastOrNull { it.type == EventType.STATE_ROOM_CREATE && it.stateKey == "" } + ?.let { it.content?.toModel() } + + if (!childPeek.isSuccess() || createContent == null) { + Timber.v("## SPACE_PEEK: cannot peek child $entry") + // can't peek :/ + spaceChildResults.add( + SpaceChildPeekResult( + childId, childPeek, entry.second?.autoJoin, entry.second?.order + ) + ) + // continue to next child + return@forEach + } + val type = createContent.type + if (type == RoomType.SPACE) { + Timber.v("## SPACE_PEEK: subspace child $entry") + spaceChildResults.add( + SpaceSubChildPeekResult( + childId, + childPeek, + entry.second?.autoJoin, + entry.second?.order, + peekChildren(childStateEvents, depth + 1, maxDepth) + ) + ) + } else + /** if (type == RoomType.MESSAGING || type == null)*/ + { + Timber.v("## SPACE_PEEK: room child $entry") + spaceChildResults.add( + SpaceChildPeekResult( + childId, childPeek, entry.second?.autoJoin, entry.second?.order + ) + ) + } + + // let's check child info + } catch (failure: Throwable) { + // can this happen? + Timber.e(failure, "## Failed to resolve space child") + } + } + return spaceChildResults + } +} diff --git a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/space/peeking/SpacePeekResult.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/space/peeking/SpacePeekResult.kt new file mode 100644 index 0000000000..1df62e94e8 --- /dev/null +++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/space/peeking/SpacePeekResult.kt @@ -0,0 +1,56 @@ +/* + * Copyright 2020 The Matrix.org Foundation C.I.C. + * + * 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 org.matrix.android.sdk.internal.session.space.peeking + +import org.matrix.android.sdk.api.session.room.peeking.PeekResult + +// TODO Move to api package +data class SpacePeekSummary( + val idOrAlias: String, + val roomPeekResult: PeekResult.Success, + val children: List +) + +interface ISpaceChild { + val id: String + val roomPeekResult: PeekResult + val default: Boolean? + val order: String? +} + +data class SpaceChildPeekResult( + override val id: String, + override val roomPeekResult: PeekResult, + override val default: Boolean? = null, + override val order: String? = null +) : ISpaceChild + +data class SpaceSubChildPeekResult( + override val id: String, + override val roomPeekResult: PeekResult, + override val default: Boolean?, + override val order: String?, + val children: List +) : ISpaceChild + +sealed class SpacePeekResult { + abstract class SpacePeekError : SpacePeekResult() + data class FailedToResolve(val spaceId: String, val roomPeekResult: PeekResult) : SpacePeekError() + data class NotSpaceType(val spaceId: String) : SpacePeekError() + + data class Success(val summary: SpacePeekSummary): SpacePeekResult() +} diff --git a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/sync/ReadReceiptHandler.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/sync/ReadReceiptHandler.kt index e5d9217db7..fc1a2c3870 100644 --- a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/sync/ReadReceiptHandler.kt +++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/sync/ReadReceiptHandler.kt @@ -143,7 +143,7 @@ internal class ReadReceiptHandler @Inject constructor( @Suppress("UNCHECKED_CAST") val content = dataFromFile .events - .firstOrNull { it.type == EventType.RECEIPT } + ?.firstOrNull { it.type == EventType.RECEIPT } ?.content as? ReadReceiptContent if (content == null) { diff --git a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/sync/RoomSyncHandler.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/sync/RoomSyncHandler.kt index 2bb606e921..7cebbb0192 100644 --- a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/sync/RoomSyncHandler.kt +++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/sync/RoomSyncHandler.kt @@ -95,8 +95,14 @@ internal class RoomSyncHandler @Inject constructor(private val readReceiptHandle handleRoomSync(realm, HandlingStrategy.JOINED(roomsSyncResponse.join), isInitialSync, aggregator, reporter) handleRoomSync(realm, HandlingStrategy.INVITED(roomsSyncResponse.invite), isInitialSync, aggregator, reporter) handleRoomSync(realm, HandlingStrategy.LEFT(roomsSyncResponse.leave), isInitialSync, aggregator, reporter) + + // post room sync validation +// roomSummaryUpdater.validateSpaceRelationship(realm) } + fun postSyncSpaceHierarchyHandle(realm: Realm) { + roomSummaryUpdater.validateSpaceRelationship(realm) + } // PRIVATE METHODS ***************************************************************************** private fun handleRoomSync(realm: Realm, @@ -212,6 +218,7 @@ internal class RoomSyncHandler @Inject constructor(private val readReceiptHandle val ageLocalTs = event.unsignedData?.age?.let { syncLocalTimestampMillis - it } val eventEntity = event.toEntity(roomId, SendState.SYNCED, ageLocalTs).copyToRealmOrIgnore(realm, insertType) CurrentStateEventEntity.getOrCreate(realm, roomId, event.stateKey, event.type).apply { + // Timber.v("## Space state event: $eventEntity") eventId = event.eventId root = eventEntity } @@ -455,7 +462,7 @@ internal class RoomSyncHandler @Inject constructor(private val readReceiptHandle } private fun handleRoomAccountDataEvents(realm: Realm, roomId: String, accountData: RoomSyncAccountData) { - for (event in accountData.events) { + accountData.events?.forEach { event -> val eventType = event.getClearType() if (eventType == EventType.TAG) { val content = event.getClearContent().toModel() diff --git a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/sync/SyncResponseHandler.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/sync/SyncResponseHandler.kt index 8e243c3443..157787c8cf 100644 --- a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/sync/SyncResponseHandler.kt +++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/sync/SyncResponseHandler.kt @@ -132,6 +132,11 @@ internal class SyncResponseHandler @Inject constructor( Timber.v("On sync completed") cryptoSyncHandler.onSyncCompleted(syncResponse) + + // post sync stuffs + monarchy.writeAsync { + roomSyncHandler.postSyncSpaceHierarchyHandle(it) + } } /** diff --git a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/sync/model/RoomSyncAccountData.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/sync/model/RoomSyncAccountData.kt index 1c35d812ee..a2375507d8 100644 --- a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/sync/model/RoomSyncAccountData.kt +++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/sync/model/RoomSyncAccountData.kt @@ -25,5 +25,5 @@ internal data class RoomSyncAccountData( /** * List of account data events (array of Event). */ - @Json(name = "events") val events: List = emptyList() + @Json(name = "events") val events: List? = null ) diff --git a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/sync/model/RoomSyncEphemeral.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/sync/model/RoomSyncEphemeral.kt index d59dddb3ea..f2135db6b7 100644 --- a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/sync/model/RoomSyncEphemeral.kt +++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/sync/model/RoomSyncEphemeral.kt @@ -26,5 +26,5 @@ internal data class RoomSyncEphemeral( /** * List of ephemeral events (array of Event). */ - @Json(name = "events") val events: List = emptyList() + @Json(name = "events") val events: List? = null ) diff --git a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/sync/model/RoomSyncState.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/sync/model/RoomSyncState.kt index 5355b7eef1..f86f05d000 100644 --- a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/sync/model/RoomSyncState.kt +++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/sync/model/RoomSyncState.kt @@ -27,5 +27,5 @@ internal data class RoomSyncState( /** * List of state events (array of Event). The resulting state corresponds to the *start* of the timeline. */ - @Json(name = "events") val events: List = emptyList() + @Json(name = "events") val events: List? = null ) diff --git a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/sync/model/RoomSyncTimeline.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/sync/model/RoomSyncTimeline.kt index ddf430099a..27bbc4343f 100644 --- a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/sync/model/RoomSyncTimeline.kt +++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/sync/model/RoomSyncTimeline.kt @@ -27,7 +27,7 @@ internal data class RoomSyncTimeline( /** * List of events (array of Event). */ - @Json(name = "events") val events: List = emptyList(), + @Json(name = "events") val events: List? = null, /** * Boolean which tells whether there are more events on the server diff --git a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/task/ConfigurableTask.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/task/ConfigurableTask.kt index 97f9a0dd51..bc80cf7ee8 100644 --- a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/task/ConfigurableTask.kt +++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/task/ConfigurableTask.kt @@ -37,7 +37,8 @@ internal data class ConfigurableTask( val id: UUID, val callbackThread: TaskThread, val executionThread: TaskThread, - val callback: MatrixCallback + val callback: MatrixCallback, + val maxRetryCount: Int = 0 ) : Task by task { @@ -57,7 +58,8 @@ internal data class ConfigurableTask( id = id, callbackThread = callbackThread, executionThread = executionThread, - callback = callback + callback = callback, + maxRetryCount = retryCount ) } diff --git a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/task/Task.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/task/Task.kt index a6c80a0b1a..a5d031e02a 100644 --- a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/task/Task.kt +++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/task/Task.kt @@ -16,7 +16,29 @@ package org.matrix.android.sdk.internal.task +import kotlinx.coroutines.delay +import org.matrix.android.sdk.api.failure.Failure +import org.matrix.android.sdk.api.failure.shouldBeRetried +import timber.log.Timber + internal interface Task { suspend fun execute(params: PARAMS): RESULT + + suspend fun executeRetry(params: PARAMS, remainingRetry: Int) : RESULT { + return try { + execute(params) + } catch (failure: Throwable) { + if (failure.shouldBeRetried() && remainingRetry > 0) { + Timber.d(failure, "## TASK: Retriable error") + if (failure is Failure.ServerError) { + val waitTime = failure.error.retryAfterMillis ?: 0L + Timber.d(failure, "## TASK: Quota wait time $waitTime") + delay(waitTime + 100) + } + return executeRetry(params, remainingRetry - 1) + } + throw failure + } + } } diff --git a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/task/TaskExecutor.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/task/TaskExecutor.kt index 478a356432..4da16eff22 100644 --- a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/task/TaskExecutor.kt +++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/task/TaskExecutor.kt @@ -40,9 +40,9 @@ internal class TaskExecutor @Inject constructor(private val coroutineDispatchers .launch(task.callbackThread.toDispatcher()) { val resultOrFailure = runCatching { withContext(task.executionThread.toDispatcher()) { - Timber.v("Enqueue task $task") - Timber.v("Execute task $task on ${Thread.currentThread().name}") - task.execute(task.params) + Timber.v("## TASK: Enqueue task $task") + Timber.v("## TASK: Execute task $task on ${Thread.currentThread().name}") + task.executeRetry(task.params, task.maxRetryCount) } } resultOrFailure diff --git a/matrix-sdk-android/src/test/java/org/matrix/android/sdk/internal/util/GraphUtilsTest.kt b/matrix-sdk-android/src/test/java/org/matrix/android/sdk/internal/util/GraphUtilsTest.kt new file mode 100644 index 0000000000..618f6f4714 --- /dev/null +++ b/matrix-sdk-android/src/test/java/org/matrix/android/sdk/internal/util/GraphUtilsTest.kt @@ -0,0 +1,111 @@ +/* + * Copyright 2021 The Matrix.org Foundation C.I.C. + * + * 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 org.matrix.android.sdk.internal.util + +import org.junit.FixMethodOrder +import org.junit.Test +import org.junit.runners.MethodSorters +import org.matrix.android.sdk.MatrixTest +import org.matrix.android.sdk.internal.session.room.summary.Graph +import kotlin.test.assertEquals +import kotlin.test.assertNotNull +import kotlin.test.assertTrue + +@FixMethodOrder(MethodSorters.JVM) +class GraphUtilsTest : MatrixTest { + + @Test + fun testCreateGraph() { + val graph = Graph() + + graph.addEdge("E", "C") + graph.addEdge("B", "A") + graph.addEdge("C", "A") + graph.addEdge("D", "C") + graph.addEdge("E", "D") + + graph.getOrCreateNode("F") + + System.out.println(graph.toString()) + + val backEdges = graph.findBackwardEdges(graph.getOrCreateNode("E")) + + assertTrue(backEdges.isEmpty(), "There should not be any cycle in this graphs") + } + + @Test + fun testCycleGraph() { + val graph = Graph() + + graph.addEdge("E", "C") + graph.addEdge("B", "A") + graph.addEdge("C", "A") + graph.addEdge("D", "C") + graph.addEdge("E", "D") + + graph.getOrCreateNode("F") + + // adding loops + graph.addEdge("C", "E") + graph.addEdge("B", "B") + + System.out.println(graph.toString()) + + val backEdges = graph.findBackwardEdges(graph.getOrCreateNode("E")) + System.out.println(backEdges.joinToString(" | ") { "${it.source.name} -> ${it.destination.name}" }) + + assertTrue(backEdges.size == 2, "There should be 2 backward edges not ${backEdges.size}") + + val edge1 = backEdges.find { it.source.name == "C" } + assertNotNull(edge1, "There should be a back edge from C") + assertEquals("E", edge1.destination.name, "There should be a back edge C -> E") + + val edge2 = backEdges.find { it.source.name == "B" } + assertNotNull(edge2, "There should be a back edge from B") + assertEquals("B", edge2.destination.name, "There should be a back edge C -> C") + + // clean the graph + val acyclicGraph = graph.withoutEdges(backEdges) + System.out.println(acyclicGraph.toString()) + + assertTrue(acyclicGraph.findBackwardEdges(acyclicGraph.getOrCreateNode("E")).isEmpty(), "There should be no backward edges") + + val flatten = acyclicGraph.flattenDestination() + + assertTrue(flatten[acyclicGraph.getOrCreateNode("A")]!!.isEmpty()) + + val flattenParentsB = flatten[acyclicGraph.getOrCreateNode("B")] + assertTrue(flattenParentsB!!.size == 1) + assertTrue(flattenParentsB.contains(acyclicGraph.getOrCreateNode("A"))) + + val flattenParentsE = flatten[acyclicGraph.getOrCreateNode("E")] + assertTrue(flattenParentsE!!.size == 3) + assertTrue(flattenParentsE.contains(acyclicGraph.getOrCreateNode("A"))) + assertTrue(flattenParentsE.contains(acyclicGraph.getOrCreateNode("C"))) + assertTrue(flattenParentsE.contains(acyclicGraph.getOrCreateNode("D"))) + +// System.out.println( +// buildString { +// flatten.entries.forEach { +// append("${it.key.name}: [") +// append(it.value.joinToString(",") { it.name }) +// append("]\n") +// } +// } +// ) + } +} diff --git a/tools/check/forbidden_strings_in_code.txt b/tools/check/forbidden_strings_in_code.txt index 5a53ececec..8f9cc852a7 100644 --- a/tools/check/forbidden_strings_in_code.txt +++ b/tools/check/forbidden_strings_in_code.txt @@ -161,7 +161,7 @@ Formatter\.formatShortFileSize===1 # android\.text\.TextUtils ### This is not a rule, but a warning: the number of "enum class" has changed. For Json classes, it is mandatory that they have `@JsonClass(generateAdapter = false)`. If the enum is not used as a Json class, change the value in file forbidden_strings_in_code.txt -enum class===94 +enum class===99 ### Do not import temporary legacy classes import org.matrix.android.sdk.internal.legacy.riot===3 diff --git a/vector/build.gradle b/vector/build.gradle index e09c7dc6f2..589844029d 100644 --- a/vector/build.gradle +++ b/vector/build.gradle @@ -333,6 +333,7 @@ dependencies { implementation "com.squareup.moshi:moshi-adapters:$moshi_version" implementation "androidx.lifecycle:lifecycle-extensions:$lifecycle_version" + implementation "androidx.lifecycle:lifecycle-livedata-ktx:2.3.1" kapt "com.squareup.moshi:moshi-kotlin-codegen:$moshi_version" // Log diff --git a/vector/sampledata/matrix.json b/vector/sampledata/matrix.json index 5328ec81b7..c69e0201ad 100644 --- a/vector/sampledata/matrix.json +++ b/vector/sampledata/matrix.json @@ -6,6 +6,7 @@ "message": "William Shakespeare (bapt. 26 April 1564 – 23 April 1616) was an English poet, playwright and actor, widely regarded as the greatest writer in the English language and the world's greatest dramatist. He is often called England's national poet and the \"Bard of Avon\". His extant works, including collaborations, consist of approximately 39 plays, 154 sonnets, two long narrative poems, and a few other verses, some of uncertain authorship. His plays have been translated into every major living language and are performed more often than those of any other playwright.\n\nShakespeare was born and raised in Stratford-upon-Avon, Warwickshire. At the age of 18, he married Anne Hathaway, with whom he had three children: Susanna and twins Hamnet and Judith. Sometime between 1585 and 1592, he began a successful career in London as an actor, writer, and part-owner of a playing company called the Lord Chamberlain's Men, later known as the King's Men. At age 49 (around 1613), he appears to have retired to Stratford, where he died three years later. Few records of Shakespeare's private life survive; this has stimulated considerable speculation about such matters as his physical appearance, his sexuality, his religious beliefs, and whether the works attributed to him were written by others. Such theories are often criticised for failing to adequately note that few records survive of most commoners of the period.\n\nShakespeare produced most of his known works between 1589 and 1613. His early plays were primarily comedies and histories and are regarded as some of the best work produced in these genres. Until about 1608, he wrote mainly tragedies, among them Hamlet, Othello, King Lear, and Macbeth, all considered to be among the finest works in the English language. In the last phase of his life, he wrote tragicomedies (also known as romances) and collaborated with other playwrights.\n\nMany of Shakespeare's plays were published in editions of varying quality and accuracy in his lifetime. However, in 1623, two fellow actors and friends of Shakespeare's, John Heminges and Henry Condell, published a more definitive text known as the First Folio, a posthumous collected edition of Shakespeare's dramatic works that included all but two of his plays. The volume was prefaced with a poem by Ben Jonson, in which Jonson presciently hails Shakespeare in a now-famous quote as \"not of an age, but for all time\".\n\nThroughout the 20th and 21st centuries, Shakespeare's works have been continually adapted and rediscovered by new movements in scholarship and performance. His plays remain popular and are studied, performed, and reinterpreted through various cultural and political contexts around the world.", "roomName": "Matrix HQ", "roomAlias": "#matrix:matrix.org", + "spaceName": "Runner's world", "roomTopic": "Welcome to Matrix HQ! Here is the rest of the room topic, with a https://www.example.org url and a phone number: 0102030405 which should not be clickable." }, { @@ -14,6 +15,7 @@ "message": "Hello!", "roomName": "Room name very loooooooong with some details", "roomAlias": "#matrix:matrix.org", + "spaceName": "Matrix Org", "roomTopic": "Room topic very loooooooong with some details" }, { @@ -22,6 +24,7 @@ "message": "How are you?", "roomName": "Room name very loooooooong with some details", "roomAlias": "#matrix:matrix.org", + "spaceName": "Rennes", "roomTopic": "Room topic very loooooooong with some details" }, { @@ -30,6 +33,7 @@ "message": "Great weather today!", "roomName": "Room name very loooooooong with some details", "roomAlias": "#matrix:matrix.org", + "spaceName": "Est London", "roomTopic": "Room topic very loooooooong with some details" }, { @@ -38,6 +42,7 @@ "message": "Let's do a picnic", "roomName": "Room name very loooooooong with some details", "roomAlias": "#matrix:matrix.org", + "spaceName": "Element HQ", "roomTopic": "Room topic very loooooooong with some details" }, { @@ -46,6 +51,7 @@ "message": "Yes, great idea", "roomName": "Room name very loooooooong with some details", "roomAlias": "#matrix:matrix.org", + "spaceName": "My Company", "roomTopic": "Room topic very loooooooong with some details" } ] diff --git a/vector/src/main/AndroidManifest.xml b/vector/src/main/AndroidManifest.xml index 9322adbe04..1e2bf1ab0f 100644 --- a/vector/src/main/AndroidManifest.xml +++ b/vector/src/main/AndroidManifest.xml @@ -291,6 +291,10 @@ + + + + >(Option.empty()) + + val selectedRoomGroupingObservable = selectedSpaceDataSource.observe() + + fun getCurrentRoomGroupingMethod(): RoomGroupingMethod? = selectedSpaceDataSource.currentValue?.orNull() + + fun setCurrentSpace(spaceId: String?, session: Session? = null) { + val uSession = session ?: activeSessionHolder.getSafeActiveSession() + if (selectedSpaceDataSource.currentValue?.orNull() is RoomGroupingMethod.BySpace + && spaceId == selectedSpaceDataSource.currentValue?.orNull()?.space()?.roomId) return + val spaceSum = spaceId?.let { uSession?.getRoomSummary(spaceId) } + selectedSpaceDataSource.post(Option.just(RoomGroupingMethod.BySpace(spaceSum))) + if (spaceId != null) { + GlobalScope.launch { + tryOrNull { + uSession?.getRoom(spaceId)?.loadRoomMembersIfNeeded() + } + } + } + } + + fun setCurrentGroup(groupId: String?, session: Session? = null) { + val uSession = session ?: activeSessionHolder.getSafeActiveSession() + if (selectedSpaceDataSource.currentValue?.orNull() is RoomGroupingMethod.ByLegacyGroup + && groupId == selectedSpaceDataSource.currentValue?.orNull()?.group()?.groupId) return + val activeGroup = groupId?.let { uSession?.getGroupSummary(groupId) } + selectedSpaceDataSource.post(Option.just(RoomGroupingMethod.ByLegacyGroup(activeGroup))) + if (groupId != null) { + GlobalScope.launch { + tryOrNull { + uSession?.getGroup(groupId)?.fetchGroupData() + } + } + } + } + + init { + sessionDataSource.observe() + .distinctUntilChanged() + .subscribe { + // sessionDataSource could already return a session while acitveSession holder still returns null + it.orNull()?.let { session -> + if (uiStateRepository.isGroupingMethodSpace(session.sessionId)) { + setCurrentSpace(uiStateRepository.getSelectedSpace(session.sessionId), session) + } else { + setCurrentGroup(uiStateRepository.getSelectedGroup(session.sessionId), session) + } + } + }.also { + compositeDisposable.add(it) + } + } + + fun safeActiveSpaceId(): String? { + return (selectedSpaceDataSource.currentValue?.orNull() as? RoomGroupingMethod.BySpace)?.spaceSummary?.roomId + } + + fun safeActiveGroupId(): String? { + return (selectedSpaceDataSource.currentValue?.orNull() as? RoomGroupingMethod.ByLegacyGroup)?.groupSummary?.groupId + } + @OnLifecycleEvent(Lifecycle.Event.ON_RESUME) fun entersForeground() { } @@ -40,5 +123,16 @@ class AppStateHandler @Inject constructor() : LifecycleObserver { @OnLifecycleEvent(Lifecycle.Event.ON_PAUSE) fun entersBackground() { compositeDisposable.clear() + val session = activeSessionHolder.getSafeActiveSession() ?: return + when (val currentMethod = selectedSpaceDataSource.currentValue?.orNull() ?: RoomGroupingMethod.BySpace(null)) { + is RoomGroupingMethod.BySpace -> { + uiStateRepository.storeGroupingMethod(true, session.sessionId) + uiStateRepository.storeSelectedSpace(currentMethod.spaceSummary?.roomId, session.sessionId) + } + is RoomGroupingMethod.ByLegacyGroup -> { + uiStateRepository.storeGroupingMethod(false, session.sessionId) + uiStateRepository.storeSelectedGroup(currentMethod.groupSummary?.groupId, session.sessionId) + } + } } } diff --git a/vector/src/main/java/im/vector/app/core/di/FragmentModule.kt b/vector/src/main/java/im/vector/app/core/di/FragmentModule.kt index 430aee5468..3d15bdd123 100644 --- a/vector/src/main/java/im/vector/app/core/di/FragmentModule.kt +++ b/vector/src/main/java/im/vector/app/core/di/FragmentModule.kt @@ -51,7 +51,6 @@ import im.vector.app.features.devtools.RoomDevToolSendFormFragment import im.vector.app.features.devtools.RoomDevToolStateEventListFragment import im.vector.app.features.discovery.DiscoverySettingsFragment import im.vector.app.features.discovery.change.SetIdentityServerFragment -import im.vector.app.features.grouplist.GroupListFragment import im.vector.app.features.home.HomeDetailFragment import im.vector.app.features.home.HomeDrawerFragment import im.vector.app.features.home.LoadingFragment @@ -72,6 +71,8 @@ import im.vector.app.features.login.LoginSplashFragment import im.vector.app.features.login.LoginWaitForEmailFragment import im.vector.app.features.login.LoginWebFragment import im.vector.app.features.login.terms.LoginTermsFragment +import im.vector.app.features.matrixto.MatrixToRoomSpaceFragment +import im.vector.app.features.matrixto.MatrixToUserFragment import im.vector.app.features.pin.PinFragment import im.vector.app.features.qrcode.QrCodeScannerFragment import im.vector.app.features.reactions.EmojiChooserFragment @@ -117,6 +118,14 @@ import im.vector.app.features.settings.push.PushRulesFragment import im.vector.app.features.settings.threepids.ThreePidsSettingsFragment import im.vector.app.features.share.IncomingShareFragment import im.vector.app.features.signout.soft.SoftLogoutFragment +import im.vector.app.features.spaces.SpaceListFragment +import im.vector.app.features.spaces.create.ChoosePrivateSpaceTypeFragment +import im.vector.app.features.spaces.create.ChooseSpaceTypeFragment +import im.vector.app.features.spaces.create.CreateSpaceDefaultRoomsFragment +import im.vector.app.features.spaces.create.CreateSpaceDetailsFragment +import im.vector.app.features.spaces.explore.SpaceDirectoryFragment +import im.vector.app.features.spaces.manage.SpaceAddRoomFragment +import im.vector.app.features.spaces.preview.SpacePreviewFragment import im.vector.app.features.terms.ReviewTermsFragment import im.vector.app.features.usercode.ShowUserCodeFragment import im.vector.app.features.userdirectory.UserListFragment @@ -142,8 +151,8 @@ interface FragmentModule { @Binds @IntoMap - @FragmentKey(GroupListFragment::class) - fun bindGroupListFragment(fragment: GroupListFragment): Fragment + @FragmentKey(SpaceListFragment::class) + fun bindSpaceListFragment(fragment: SpaceListFragment): Fragment @Binds @IntoMap @@ -624,4 +633,49 @@ interface FragmentModule { @IntoMap @FragmentKey(RoomDevToolSendFormFragment::class) fun bindRoomDevToolSendFormFragment(fragment: RoomDevToolSendFormFragment): Fragment + + @Binds + @IntoMap + @FragmentKey(SpacePreviewFragment::class) + fun bindSpacePreviewFragment(fragment: SpacePreviewFragment): Fragment + + @Binds + @IntoMap + @FragmentKey(ChooseSpaceTypeFragment::class) + fun bindChooseSpaceTypeFragment(fragment: ChooseSpaceTypeFragment): Fragment + + @Binds + @IntoMap + @FragmentKey(CreateSpaceDetailsFragment::class) + fun bindCreateSpaceDetailsFragment(fragment: CreateSpaceDetailsFragment): Fragment + + @Binds + @IntoMap + @FragmentKey(CreateSpaceDefaultRoomsFragment::class) + fun bindCreateSpaceDefaultRoomsFragment(fragment: CreateSpaceDefaultRoomsFragment): Fragment + + @Binds + @IntoMap + @FragmentKey(MatrixToUserFragment::class) + fun bindMatrixToUserFragment(fragment: MatrixToUserFragment): Fragment + + @Binds + @IntoMap + @FragmentKey(MatrixToRoomSpaceFragment::class) + fun bindMatrixToRoomSpaceFragment(fragment: MatrixToRoomSpaceFragment): Fragment + + @Binds + @IntoMap + @FragmentKey(SpaceDirectoryFragment::class) + fun bindSpaceDirectoryFragment(fragment: SpaceDirectoryFragment): Fragment + + @Binds + @IntoMap + @FragmentKey(ChoosePrivateSpaceTypeFragment::class) + fun bindChoosePrivateSpaceTypeFragment(fragment: ChoosePrivateSpaceTypeFragment): Fragment + + @Binds + @IntoMap + @FragmentKey(SpaceAddRoomFragment::class) + fun bindSpaceAddRoomFragment(fragment: SpaceAddRoomFragment): Fragment } diff --git a/vector/src/main/java/im/vector/app/core/di/ScreenComponent.kt b/vector/src/main/java/im/vector/app/core/di/ScreenComponent.kt index a1de892c4e..b6c75beb02 100644 --- a/vector/src/main/java/im/vector/app/core/di/ScreenComponent.kt +++ b/vector/src/main/java/im/vector/app/core/di/ScreenComponent.kt @@ -77,6 +77,12 @@ import im.vector.app.features.settings.VectorSettingsActivity import im.vector.app.features.settings.devices.DeviceVerificationInfoBottomSheet import im.vector.app.features.share.IncomingShareActivity import im.vector.app.features.signout.soft.SoftLogoutActivity +import im.vector.app.features.spaces.InviteRoomSpaceChooserBottomSheet +import im.vector.app.features.spaces.ShareSpaceBottomSheet +import im.vector.app.features.spaces.SpaceCreationActivity +import im.vector.app.features.spaces.SpaceExploreActivity +import im.vector.app.features.spaces.SpaceSettingsMenuBottomSheet +import im.vector.app.features.spaces.manage.SpaceManageActivity import im.vector.app.features.terms.ReviewTermsActivity import im.vector.app.features.ui.UiStateRepository import im.vector.app.features.usercode.UserCodeActivity @@ -151,6 +157,9 @@ interface ScreenComponent { fun inject(activity: CallTransferActivity) fun inject(activity: ReAuthActivity) fun inject(activity: RoomDevToolActivity) + fun inject(activity: SpaceCreationActivity) + fun inject(activity: SpaceExploreActivity) + fun inject(activity: SpaceManageActivity) /* ========================================================================================== * BottomSheets @@ -173,6 +182,9 @@ interface ScreenComponent { fun inject(bottomSheet: CallControlsBottomSheet) fun inject(bottomSheet: SignOutBottomSheetDialogFragment) fun inject(bottomSheet: MatrixToBottomSheet) + fun inject(bottomSheet: ShareSpaceBottomSheet) + fun inject(bottomSheet: SpaceSettingsMenuBottomSheet) + fun inject(bottomSheet: InviteRoomSpaceChooserBottomSheet) /* ========================================================================================== * Others diff --git a/vector/src/main/java/im/vector/app/core/di/VectorComponent.kt b/vector/src/main/java/im/vector/app/core/di/VectorComponent.kt index 4b88ff6767..e5a47e872c 100644 --- a/vector/src/main/java/im/vector/app/core/di/VectorComponent.kt +++ b/vector/src/main/java/im/vector/app/core/di/VectorComponent.kt @@ -21,6 +21,7 @@ import android.content.res.Resources import dagger.BindsInstance import dagger.Component import im.vector.app.ActiveSessionDataSource +import im.vector.app.AppStateHandler import im.vector.app.EmojiCompatFontProvider import im.vector.app.EmojiCompatWrapper import im.vector.app.VectorApplication @@ -34,8 +35,8 @@ import im.vector.app.features.call.webrtc.WebRtcCallManager import im.vector.app.features.configuration.VectorConfiguration import im.vector.app.features.crypto.keysrequest.KeyRequestHandler import im.vector.app.features.crypto.verification.IncomingVerificationRequestHandler -import im.vector.app.features.grouplist.SelectedGroupDataSource import im.vector.app.features.home.AvatarRenderer +import im.vector.app.features.home.CurrentSpaceSuggestedRoomListDataSource import im.vector.app.features.home.room.detail.RoomDetailPendingActionStore import im.vector.app.features.home.room.detail.timeline.helper.MatrixItemColorProvider import im.vector.app.features.home.room.detail.timeline.helper.RoomSummariesHolder @@ -113,7 +114,9 @@ interface VectorComponent { fun errorFormatter(): ErrorFormatter - fun selectedGroupStore(): SelectedGroupDataSource + fun appStateHandler(): AppStateHandler + + fun currentSpaceSuggestedRoomListDataSource(): CurrentSpaceSuggestedRoomListDataSource fun roomDetailPendingActionStore(): RoomDetailPendingActionStore diff --git a/vector/src/main/java/im/vector/app/core/di/ViewModelModule.kt b/vector/src/main/java/im/vector/app/core/di/ViewModelModule.kt index 8409021845..2639e87e6e 100644 --- a/vector/src/main/java/im/vector/app/core/di/ViewModelModule.kt +++ b/vector/src/main/java/im/vector/app/core/di/ViewModelModule.kt @@ -38,6 +38,7 @@ import im.vector.app.features.roomprofile.RoomProfileSharedActionViewModel import im.vector.app.features.roomprofile.alias.detail.RoomAliasBottomSheetSharedActionViewModel import im.vector.app.features.roomprofile.settings.historyvisibility.RoomHistoryVisibilitySharedActionViewModel import im.vector.app.features.roomprofile.settings.joinrule.RoomJoinRuleSharedActionViewModel +import im.vector.app.features.spaces.SpacePreviewSharedActionViewModel import im.vector.app.features.userdirectory.UserListSharedActionViewModel @Module @@ -142,4 +143,9 @@ interface ViewModelModule { @IntoMap @ViewModelKey(DiscoverySharedViewModel::class) fun bindDiscoverySharedViewModel(viewModel: DiscoverySharedViewModel): ViewModel + + @Binds + @IntoMap + @ViewModelKey(SpacePreviewSharedActionViewModel::class) + fun bindSpacePreviewSharedActionViewModel(viewModel: SpacePreviewSharedActionViewModel): ViewModel } diff --git a/vector/src/main/java/im/vector/app/core/epoxy/bottomsheet/BottomSheetRadioActionItem.kt b/vector/src/main/java/im/vector/app/core/epoxy/bottomsheet/BottomSheetRadioActionItem.kt new file mode 100644 index 0000000000..6ca1182cce --- /dev/null +++ b/vector/src/main/java/im/vector/app/core/epoxy/bottomsheet/BottomSheetRadioActionItem.kt @@ -0,0 +1,80 @@ +/* + * Copyright 2019 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 im.vector.app.core.epoxy.bottomsheet + +import android.view.View +import android.widget.ImageView +import android.widget.TextView +import androidx.annotation.StringRes +import androidx.core.content.ContextCompat +import com.airbnb.epoxy.EpoxyAttribute +import com.airbnb.epoxy.EpoxyModelClass +import im.vector.app.R +import im.vector.app.core.epoxy.VectorEpoxyHolder +import im.vector.app.core.epoxy.VectorEpoxyModel +import im.vector.app.core.extensions.setTextOrHide + +/** + * A action for bottom sheet. + */ +@EpoxyModelClass(layout = R.layout.item_bottom_sheet_radio) +abstract class BottomSheetRadioActionItem : VectorEpoxyModel() { + + @EpoxyAttribute + var title: CharSequence? = null + + @StringRes + @EpoxyAttribute + var titleRes: Int? = null + + @EpoxyAttribute + var selected = false + + @EpoxyAttribute + var description: CharSequence? = null + + @EpoxyAttribute + lateinit var listener: View.OnClickListener + + override fun bind(holder: Holder) { + super.bind(holder) + holder.view.setOnClickListener { + listener.onClick(it) + } + + if (titleRes != null) { + holder.titleText.setText(titleRes!!) + } else { + holder.titleText.text = title + } + holder.descriptionText.setTextOrHide(description) + + if (selected) { + holder.radioImage.setImageDrawable(ContextCompat.getDrawable(holder.view.context, R.drawable.ic_radio_on)) + holder.radioImage.contentDescription = holder.view.context.getString(R.string.a11y_checked) + } else { + holder.radioImage.setImageDrawable(ContextCompat.getDrawable(holder.view.context, R.drawable.ic_radio_off)) + holder.radioImage.contentDescription = holder.view.context.getString(R.string.a11y_unchecked) + } + } + + class Holder : VectorEpoxyHolder() { + val titleText by bind(R.id.actionTitle) + val descriptionText by bind(R.id.actionDescription) + val radioImage by bind(R.id.radioIcon) + } +} diff --git a/vector/src/main/java/im/vector/app/core/platform/livedata/SharedPreferenceLiveData.kt b/vector/src/main/java/im/vector/app/core/platform/livedata/SharedPreferenceLiveData.kt new file mode 100644 index 0000000000..3e0733b35d --- /dev/null +++ b/vector/src/main/java/im/vector/app/core/platform/livedata/SharedPreferenceLiveData.kt @@ -0,0 +1,54 @@ +/* + * Copyright (c) 2021 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 im.vector.app.core.platform.livedata + +import android.content.SharedPreferences +import androidx.lifecycle.LiveData + +abstract class SharedPreferenceLiveData(protected val sharedPrefs: SharedPreferences, + protected val key: String, + private val defValue: T) : LiveData() { + + private val preferenceChangeListener = SharedPreferences.OnSharedPreferenceChangeListener { _, key -> + if (key == this.key) { + value = getValueFromPreferences(key, defValue) + } + } + + abstract fun getValueFromPreferences(key: String, defValue: T): T + + override fun onActive() { + super.onActive() + value = getValueFromPreferences(key, defValue) + sharedPrefs.registerOnSharedPreferenceChangeListener(preferenceChangeListener) + } + + override fun onInactive() { + sharedPrefs.unregisterOnSharedPreferenceChangeListener(preferenceChangeListener) + super.onInactive() + } + + companion object { + fun booleanLiveData(sharedPrefs: SharedPreferences, key: String, defaultValue: Boolean): SharedPreferenceLiveData { + return object : SharedPreferenceLiveData(sharedPrefs, key, defaultValue) { + override fun getValueFromPreferences(key: String, defValue: Boolean): Boolean { + return this.sharedPrefs.getBoolean(key, defValue) + } + } + } + } +} diff --git a/vector/src/main/java/im/vector/app/core/ui/bottomsheet/BottomSheetGeneric.kt b/vector/src/main/java/im/vector/app/core/ui/bottomsheet/BottomSheetGeneric.kt index f8f345f09d..e773993b21 100644 --- a/vector/src/main/java/im/vector/app/core/ui/bottomsheet/BottomSheetGeneric.kt +++ b/vector/src/main/java/im/vector/app/core/ui/bottomsheet/BottomSheetGeneric.kt @@ -32,7 +32,7 @@ import javax.inject.Inject /** * Generic Bottom sheet with actions */ -abstract class BottomSheetGeneric : +abstract class BottomSheetGeneric : VectorBaseBottomSheetDialogFragment(), BottomSheetGenericController.Listener { diff --git a/vector/src/main/java/im/vector/app/core/ui/bottomsheet/BottomSheetGenericController.kt b/vector/src/main/java/im/vector/app/core/ui/bottomsheet/BottomSheetGenericController.kt index 67347c3220..c5e0c51047 100644 --- a/vector/src/main/java/im/vector/app/core/ui/bottomsheet/BottomSheetGenericController.kt +++ b/vector/src/main/java/im/vector/app/core/ui/bottomsheet/BottomSheetGenericController.kt @@ -17,12 +17,11 @@ package im.vector.app.core.ui.bottomsheet import android.view.View import com.airbnb.epoxy.TypedEpoxyController -import im.vector.app.core.epoxy.dividerItem /** * Epoxy controller for generic bottom sheet actions */ -abstract class BottomSheetGenericController +abstract class BottomSheetGenericController : TypedEpoxyController() { var listener: Listener? = null @@ -43,16 +42,14 @@ abstract class BottomSheetGenericController 0 } actions.forEach { action -> - action.toBottomSheetItem() - .showIcon(showIcons) + action.toRadioBottomSheetItem() .listener(View.OnClickListener { listener?.didSelectAction(action) }) .addTo(this) } diff --git a/vector/src/main/java/im/vector/app/core/ui/bottomsheet/BottomSheetGenericAction.kt b/vector/src/main/java/im/vector/app/core/ui/bottomsheet/BottomSheetGenericRadioAction.kt similarity index 58% rename from vector/src/main/java/im/vector/app/core/ui/bottomsheet/BottomSheetGenericAction.kt rename to vector/src/main/java/im/vector/app/core/ui/bottomsheet/BottomSheetGenericRadioAction.kt index da48accf35..516612717a 100644 --- a/vector/src/main/java/im/vector/app/core/ui/bottomsheet/BottomSheetGenericAction.kt +++ b/vector/src/main/java/im/vector/app/core/ui/bottomsheet/BottomSheetGenericRadioAction.kt @@ -16,27 +16,24 @@ package im.vector.app.core.ui.bottomsheet -import androidx.annotation.DrawableRes -import im.vector.app.core.epoxy.bottomsheet.BottomSheetActionItem_ +import im.vector.app.core.epoxy.bottomsheet.BottomSheetRadioActionItem_ import im.vector.app.core.platform.VectorSharedAction /** * Parent class for a bottom sheet action */ -open class BottomSheetGenericAction( - open val title: String, - @DrawableRes open val iconResId: Int, - open val isSelected: Boolean, - open val destructive: Boolean +open class BottomSheetGenericRadioAction( + open val title: CharSequence?, + open val description: String? = null, + open val isSelected: Boolean ) : VectorSharedAction { - fun toBottomSheetItem(): BottomSheetActionItem_ { - return BottomSheetActionItem_().apply { - id("action_$title") - iconRes(iconResId) - text(title) - selected(isSelected) - destructive(destructive) + fun toRadioBottomSheetItem(): BottomSheetRadioActionItem_ { + return BottomSheetRadioActionItem_().also { + it.id("action_$title") + it.title(title) + it.selected(isSelected) + it.description(description) } } } diff --git a/vector/src/main/java/im/vector/app/core/ui/list/GenericFooterItem.kt b/vector/src/main/java/im/vector/app/core/ui/list/GenericFooterItem.kt index 2539e59ae4..e185a4dbc4 100644 --- a/vector/src/main/java/im/vector/app/core/ui/list/GenericFooterItem.kt +++ b/vector/src/main/java/im/vector/app/core/ui/list/GenericFooterItem.kt @@ -36,10 +36,10 @@ import im.vector.app.features.themes.ThemeUtils abstract class GenericFooterItem : VectorEpoxyModel() { @EpoxyAttribute - var text: String? = null + var text: CharSequence? = null @EpoxyAttribute - var style: GenericItem.STYLE = GenericItem.STYLE.NORMAL_TEXT + var style: ItemStyle = ItemStyle.NORMAL_TEXT @EpoxyAttribute var itemClickAction: GenericItem.Action? = null @@ -53,11 +53,10 @@ abstract class GenericFooterItem : VectorEpoxyModel() override fun bind(holder: Holder) { super.bind(holder) + holder.text.setTextOrHide(text) - when (style) { - GenericItem.STYLE.BIG_TEXT -> holder.text.textSize = 18f - GenericItem.STYLE.NORMAL_TEXT -> holder.text.textSize = 14f - } + holder.text.typeface = style.toTypeFace() + holder.text.textSize = style.toTextSize() holder.text.gravity = if (centered) Gravity.CENTER_HORIZONTAL else Gravity.START if (textColor != null) { diff --git a/vector/src/main/java/im/vector/app/core/ui/list/GenericItem.kt b/vector/src/main/java/im/vector/app/core/ui/list/GenericItem.kt index 3a1337e78c..8a01183915 100644 --- a/vector/src/main/java/im/vector/app/core/ui/list/GenericItem.kt +++ b/vector/src/main/java/im/vector/app/core/ui/list/GenericItem.kt @@ -38,11 +38,6 @@ import im.vector.app.core.extensions.setTextOrHide @EpoxyModelClass(layout = R.layout.item_generic_list) abstract class GenericItem : VectorEpoxyModel() { - enum class STYLE { - BIG_TEXT, - NORMAL_TEXT - } - class Action(var title: String) { var perform: Runnable? = null } @@ -54,7 +49,7 @@ abstract class GenericItem : VectorEpoxyModel() { var description: CharSequence? = null @EpoxyAttribute - var style: STYLE = STYLE.NORMAL_TEXT + var style: ItemStyle = ItemStyle.NORMAL_TEXT @EpoxyAttribute @DrawableRes @@ -87,10 +82,7 @@ abstract class GenericItem : VectorEpoxyModel() { holder.titleIcon.isVisible = false } - when (style) { - STYLE.BIG_TEXT -> holder.titleText.textSize = 18f - STYLE.NORMAL_TEXT -> holder.titleText.textSize = 14f - } + holder.titleText.textSize = style.toTextSize() holder.descriptionText.setTextOrHide(description) diff --git a/vector/src/main/java/im/vector/app/core/ui/list/ItemStyle.kt b/vector/src/main/java/im/vector/app/core/ui/list/ItemStyle.kt new file mode 100644 index 0000000000..b98d29040d --- /dev/null +++ b/vector/src/main/java/im/vector/app/core/ui/list/ItemStyle.kt @@ -0,0 +1,43 @@ +/* + * Copyright (c) 2021 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 im.vector.app.core.ui.list + +import android.graphics.Typeface + +enum class ItemStyle { + BIG_TEXT, + NORMAL_TEXT, + TITLE, + SUBHEADER; + + fun toTypeFace(): Typeface { + return if (this == TITLE) { + Typeface.DEFAULT_BOLD + } else { + Typeface.DEFAULT + } + } + + fun toTextSize(): Float { + return when (this) { + BIG_TEXT -> 18f + NORMAL_TEXT -> 14f + TITLE -> 20f + SUBHEADER -> 16f + } + } +} diff --git a/vector/src/main/java/im/vector/app/features/autocomplete/command/AutocompleteCommandPresenter.kt b/vector/src/main/java/im/vector/app/features/autocomplete/command/AutocompleteCommandPresenter.kt index d121c68557..5ad31aeaa6 100644 --- a/vector/src/main/java/im/vector/app/features/autocomplete/command/AutocompleteCommandPresenter.kt +++ b/vector/src/main/java/im/vector/app/features/autocomplete/command/AutocompleteCommandPresenter.kt @@ -21,10 +21,12 @@ import androidx.recyclerview.widget.RecyclerView import im.vector.app.features.autocomplete.AutocompleteClickListener import im.vector.app.features.autocomplete.RecyclerViewPresenter import im.vector.app.features.command.Command +import im.vector.app.features.settings.VectorPreferences import javax.inject.Inject class AutocompleteCommandPresenter @Inject constructor(context: Context, - private val controller: AutocompleteCommandController) : + private val controller: AutocompleteCommandController, + private val vectorPreferences: VectorPreferences) : RecyclerViewPresenter(context), AutocompleteClickListener { init { @@ -40,13 +42,17 @@ class AutocompleteCommandPresenter @Inject constructor(context: Context, } override fun onQuery(query: CharSequence?) { - val data = Command.values().filter { - if (query.isNullOrEmpty()) { - true - } else { - it.command.startsWith(query, 1, true) - } - } + val data = Command.values() + .filter { + !it.isDevCommand || vectorPreferences.developerMode() + } + .filter { + if (query.isNullOrEmpty()) { + true + } else { + it.command.startsWith(query, 1, true) + } + } controller.setData(data) } diff --git a/vector/src/main/java/im/vector/app/features/command/Command.kt b/vector/src/main/java/im/vector/app/features/command/Command.kt index 66d88f149a..0b210cf298 100644 --- a/vector/src/main/java/im/vector/app/features/command/Command.kt +++ b/vector/src/main/java/im/vector/app/features/command/Command.kt @@ -24,29 +24,33 @@ import im.vector.app.R * the user can write theses messages to perform some actions * the list will be displayed in this order */ -enum class Command(val command: String, val parameters: String, @StringRes val description: Int) { - EMOTE("/me", "", R.string.command_description_emote), - BAN_USER("/ban", " [reason]", R.string.command_description_ban_user), - UNBAN_USER("/unban", " [reason]", R.string.command_description_unban_user), - SET_USER_POWER_LEVEL("/op", " []", R.string.command_description_op_user), - RESET_USER_POWER_LEVEL("/deop", "", R.string.command_description_deop_user), - INVITE("/invite", " [reason]", R.string.command_description_invite_user), - JOIN_ROOM("/join", " [reason]", R.string.command_description_join_room), - PART("/part", " [reason]", R.string.command_description_part_room), - TOPIC("/topic", "", R.string.command_description_topic), - KICK_USER("/kick", " [reason]", R.string.command_description_kick_user), - CHANGE_DISPLAY_NAME("/nick", "", R.string.command_description_nick), - MARKDOWN("/markdown", "", R.string.command_description_markdown), - RAINBOW("/rainbow", "", R.string.command_description_rainbow), - RAINBOW_EMOTE("/rainbowme", "", R.string.command_description_rainbow_emote), - CLEAR_SCALAR_TOKEN("/clear_scalar_token", "", R.string.command_description_clear_scalar_token), - SPOILER("/spoiler", "", R.string.command_description_spoiler), - POLL("/poll", "Question | Option 1 | Option 2 ...", R.string.command_description_poll), - SHRUG("/shrug", "", R.string.command_description_shrug), - PLAIN("/plain", "", R.string.command_description_plain), - DISCARD_SESSION("/discardsession", "", R.string.command_description_discard_session), - CONFETTI("/confetti", "", R.string.command_confetti), - SNOW("/snow", "", R.string.command_snow); +enum class Command(val command: String, val parameters: String, @StringRes val description: Int, val isDevCommand: Boolean) { + EMOTE("/me", "", R.string.command_description_emote, false), + BAN_USER("/ban", " [reason]", R.string.command_description_ban_user, false), + UNBAN_USER("/unban", " [reason]", R.string.command_description_unban_user, false), + SET_USER_POWER_LEVEL("/op", " []", R.string.command_description_op_user, false), + RESET_USER_POWER_LEVEL("/deop", "", R.string.command_description_deop_user, false), + INVITE("/invite", " [reason]", R.string.command_description_invite_user, false), + JOIN_ROOM("/join", " [reason]", R.string.command_description_join_room, false), + PART("/part", " [reason]", R.string.command_description_part_room, false), + TOPIC("/topic", "", R.string.command_description_topic, false), + KICK_USER("/kick", " [reason]", R.string.command_description_kick_user, false), + CHANGE_DISPLAY_NAME("/nick", "", R.string.command_description_nick, false), + MARKDOWN("/markdown", "", R.string.command_description_markdown, false), + RAINBOW("/rainbow", "", R.string.command_description_rainbow, false), + RAINBOW_EMOTE("/rainbowme", "", R.string.command_description_rainbow_emote, false), + CLEAR_SCALAR_TOKEN("/clear_scalar_token", "", R.string.command_description_clear_scalar_token, false), + SPOILER("/spoiler", "", R.string.command_description_spoiler, false), + POLL("/poll", "Question | Option 1 | Option 2 ...", R.string.command_description_poll, false), + SHRUG("/shrug", "", R.string.command_description_shrug, false), + PLAIN("/plain", "", R.string.command_description_plain, false), + DISCARD_SESSION("/discardsession", "", R.string.command_description_discard_session, false), + CONFETTI("/confetti", "", R.string.command_confetti, false), + SNOW("/snow", "", R.string.command_snow, false), + CREATE_SPACE("/createspace", " *", R.string.command_description_create_space, true), + ADD_TO_SPACE("/addToSpace", "spaceId", R.string.command_description_create_space, true), + JOIN_SPACE("/joinSpace", "spaceId", R.string.command_description_join_space, true), + LEAVE_ROOM("/leave", "", R.string.command_description_leave_room, true); val length get() = command.length + 1 diff --git a/vector/src/main/java/im/vector/app/features/command/CommandParser.kt b/vector/src/main/java/im/vector/app/features/command/CommandParser.kt index d458751364..9b190d64fe 100644 --- a/vector/src/main/java/im/vector/app/features/command/CommandParser.kt +++ b/vector/src/main/java/im/vector/app/features/command/CommandParser.kt @@ -296,10 +296,40 @@ object CommandParser { val message = textMessage.substring(Command.CONFETTI.command.length).trim() ParsedCommand.SendChatEffect(ChatEffect.CONFETTI, message) } - Command.SNOW.command -> { + Command.SNOW.command -> { val message = textMessage.substring(Command.SNOW.command.length).trim() ParsedCommand.SendChatEffect(ChatEffect.SNOW, message) } + Command.CREATE_SPACE.command -> { + val rawCommand = textMessage.substring(Command.CREATE_SPACE.command.length).trim() + val split = rawCommand.split(" ").map { it.trim() } + if (split.isEmpty()) { + ParsedCommand.ErrorSyntax(Command.CREATE_SPACE) + } else { + ParsedCommand.CreateSpace( + split[0], + split.subList(1, split.size) + ) + } + } + Command.ADD_TO_SPACE.command -> { + val rawCommand = textMessage.substring(Command.ADD_TO_SPACE.command.length).trim() + ParsedCommand.AddToSpace( + rawCommand + ) + } + Command.JOIN_SPACE.command -> { + val spaceIdOrAlias = textMessage.substring(Command.JOIN_SPACE.command.length).trim() + ParsedCommand.JoinSpace( + spaceIdOrAlias + ) + } + Command.LEAVE_ROOM.command -> { + val spaceIdOrAlias = textMessage.substring(Command.LEAVE_ROOM.command.length).trim() + ParsedCommand.LeaveRoom( + spaceIdOrAlias + ) + } else -> { // Unknown command ParsedCommand.ErrorUnknownSlashCommand(slashCommand) diff --git a/vector/src/main/java/im/vector/app/features/command/ParsedCommand.kt b/vector/src/main/java/im/vector/app/features/command/ParsedCommand.kt index d17faeafb8..d67caac60a 100644 --- a/vector/src/main/java/im/vector/app/features/command/ParsedCommand.kt +++ b/vector/src/main/java/im/vector/app/features/command/ParsedCommand.kt @@ -57,4 +57,8 @@ sealed class ParsedCommand { class SendPoll(val question: String, val options: List) : ParsedCommand() object DiscardSession : ParsedCommand() class SendChatEffect(val chatEffect: ChatEffect, val message: String) : ParsedCommand() + class CreateSpace(val name: String, val invitees: List) : ParsedCommand() + class AddToSpace(val spaceId: String) : ParsedCommand() + class JoinSpace(val spaceIdOrAlias: String) : ParsedCommand() + class LeaveRoom(val roomId: String) : ParsedCommand() } diff --git a/vector/src/main/java/im/vector/app/features/crypto/keysbackup/settings/KeysBackupSettingsRecyclerViewController.kt b/vector/src/main/java/im/vector/app/features/crypto/keysbackup/settings/KeysBackupSettingsRecyclerViewController.kt index bfdb297b23..ca5f88968a 100644 --- a/vector/src/main/java/im/vector/app/features/crypto/keysbackup/settings/KeysBackupSettingsRecyclerViewController.kt +++ b/vector/src/main/java/im/vector/app/features/crypto/keysbackup/settings/KeysBackupSettingsRecyclerViewController.kt @@ -26,7 +26,7 @@ import im.vector.app.R import im.vector.app.core.epoxy.errorWithRetryItem import im.vector.app.core.epoxy.loadingItem import im.vector.app.core.resources.StringProvider -import im.vector.app.core.ui.list.GenericItem +import im.vector.app.core.ui.list.ItemStyle import im.vector.app.core.ui.list.genericItem import im.vector.app.features.settings.VectorPreferences import org.matrix.android.sdk.api.session.Session @@ -72,7 +72,7 @@ class KeysBackupSettingsRecyclerViewController @Inject constructor(private val s genericItem { id("summary") title(stringProvider.getString(R.string.keys_backup_settings_status_not_setup)) - style(GenericItem.STYLE.BIG_TEXT) + style(ItemStyle.BIG_TEXT) if (data.keysBackupVersionTrust()?.usable == false) { description(stringProvider.getString(R.string.keys_backup_settings_untrusted_backup)) @@ -87,7 +87,7 @@ class KeysBackupSettingsRecyclerViewController @Inject constructor(private val s genericItem { id("summary") title(stringProvider.getString(R.string.keys_backup_settings_status_ko)) - style(GenericItem.STYLE.BIG_TEXT) + style(ItemStyle.BIG_TEXT) if (data.keysBackupVersionTrust()?.usable == false) { description(stringProvider.getString(R.string.keys_backup_settings_untrusted_backup)) } else { @@ -102,7 +102,7 @@ class KeysBackupSettingsRecyclerViewController @Inject constructor(private val s genericItem { id("summary") title(stringProvider.getString(R.string.keys_backup_settings_status_ok)) - style(GenericItem.STYLE.BIG_TEXT) + style(ItemStyle.BIG_TEXT) if (data.keysBackupVersionTrust()?.usable == false) { description(stringProvider.getString(R.string.keys_backup_settings_untrusted_backup)) } else { @@ -118,7 +118,7 @@ class KeysBackupSettingsRecyclerViewController @Inject constructor(private val s genericItem { id("summary") title(stringProvider.getString(R.string.keys_backup_settings_status_ok)) - style(GenericItem.STYLE.BIG_TEXT) + style(ItemStyle.BIG_TEXT) hasIndeterminateProcess(true) val totalKeys = session.cryptoService().inboundGroupSessionsCount(false) diff --git a/vector/src/main/java/im/vector/app/features/form/FormEditTextItem.kt b/vector/src/main/java/im/vector/app/features/form/FormEditTextItem.kt index 68e2e6b371..fdac8afaed 100644 --- a/vector/src/main/java/im/vector/app/features/form/FormEditTextItem.kt +++ b/vector/src/main/java/im/vector/app/features/form/FormEditTextItem.kt @@ -18,6 +18,7 @@ package im.vector.app.features.form import android.text.Editable import android.view.View +import android.view.inputmethod.EditorInfo import androidx.core.view.isVisible import com.airbnb.epoxy.EpoxyAttribute import com.airbnb.epoxy.EpoxyModelClass @@ -50,6 +51,15 @@ abstract class FormEditTextItem : VectorEpoxyModel() { @EpoxyAttribute var inputType: Int? = null + @EpoxyAttribute + var singleLine: Boolean? = null + + @EpoxyAttribute + var imeOptions: Int? = null + + @EpoxyAttribute + var endIconMode: Int? = null + @EpoxyAttribute var onTextChange: ((String) -> Unit)? = null @@ -64,11 +74,14 @@ abstract class FormEditTextItem : VectorEpoxyModel() { holder.textInputLayout.isEnabled = enabled holder.textInputLayout.hint = hint holder.textInputLayout.error = errorMessage + holder.textInputLayout.endIconMode = endIconMode ?: TextInputLayout.END_ICON_NONE // Update only if text is different and value is not null holder.textInputEditText.setTextSafe(value) holder.textInputEditText.isEnabled = enabled inputType?.let { holder.textInputEditText.inputType = it } + holder.textInputEditText.isSingleLine = singleLine ?: false + holder.textInputEditText.imeOptions = imeOptions ?: EditorInfo.IME_ACTION_NONE holder.textInputEditText.addTextChangedListener(onTextChangeListener) holder.bottomSeparator.isVisible = showBottomSeparator diff --git a/vector/src/main/java/im/vector/app/features/form/FormEditableSquareAvatarItem.kt b/vector/src/main/java/im/vector/app/features/form/FormEditableSquareAvatarItem.kt new file mode 100644 index 0000000000..cbb545825d --- /dev/null +++ b/vector/src/main/java/im/vector/app/features/form/FormEditableSquareAvatarItem.kt @@ -0,0 +1,95 @@ +/* + * Copyright (c) 2020 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 im.vector.app.features.form + +import android.net.Uri +import android.util.TypedValue +import android.view.View +import android.widget.ImageView +import androidx.core.view.isVisible +import com.airbnb.epoxy.EpoxyAttribute +import com.airbnb.epoxy.EpoxyModelClass +import com.airbnb.epoxy.EpoxyModelWithHolder +import com.bumptech.glide.load.MultiTransformation +import com.bumptech.glide.load.resource.bitmap.CenterCrop +import com.bumptech.glide.load.resource.bitmap.RoundedCorners +import im.vector.app.R +import im.vector.app.core.epoxy.ClickListener +import im.vector.app.core.epoxy.VectorEpoxyHolder +import im.vector.app.core.epoxy.onClick +import im.vector.app.core.glide.GlideApp +import im.vector.app.features.home.AvatarRenderer +import org.matrix.android.sdk.api.util.MatrixItem + +@EpoxyModelClass(layout = R.layout.item_editable_square_avatar) +abstract class FormEditableSquareAvatarItem : EpoxyModelWithHolder() { + + @EpoxyAttribute + var avatarRenderer: AvatarRenderer? = null + + @EpoxyAttribute + var matrixItem: MatrixItem? = null + + @EpoxyAttribute + var enabled: Boolean = true + + @EpoxyAttribute + var imageUri: Uri? = null + + @EpoxyAttribute + var clickListener: ClickListener? = null + + @EpoxyAttribute + var deleteListener: ClickListener? = null + + override fun bind(holder: Holder) { + super.bind(holder) + holder.imageContainer.onClick(clickListener?.takeIf { enabled }) + when { + imageUri != null -> { + val corner = TypedValue.applyDimension( + TypedValue.COMPLEX_UNIT_DIP, + 8f, + holder.view.resources.displayMetrics + ).toInt() + GlideApp.with(holder.image) + .load(imageUri) + .transform(MultiTransformation(CenterCrop(), RoundedCorners(corner))) + .into(holder.image) + } + matrixItem != null -> { + avatarRenderer?.renderSpace(matrixItem!!, holder.image) + } + else -> { + avatarRenderer?.clear(holder.image) + } + } + holder.delete.isVisible = enabled && (imageUri != null || matrixItem?.avatarUrl?.isNotEmpty() == true) + holder.delete.onClick(deleteListener?.takeIf { enabled }) + } + + override fun unbind(holder: Holder) { + avatarRenderer?.clear(holder.image) + GlideApp.with(holder.image).clear(holder.image) + super.unbind(holder) + } + + class Holder : VectorEpoxyHolder() { + val imageContainer by bind(R.id.itemEditableAvatarImageContainer) + val image by bind(R.id.itemEditableAvatarImage) + val delete by bind(R.id.itemEditableAvatarDelete) + } +} diff --git a/vector/src/main/java/im/vector/app/features/form/FormMultiLineEditTextItem.kt b/vector/src/main/java/im/vector/app/features/form/FormMultiLineEditTextItem.kt index 4ba668a051..6c6f6d284d 100644 --- a/vector/src/main/java/im/vector/app/features/form/FormMultiLineEditTextItem.kt +++ b/vector/src/main/java/im/vector/app/features/form/FormMultiLineEditTextItem.kt @@ -73,7 +73,7 @@ abstract class FormMultiLineEditTextItem : VectorEpoxyModel(initialState) { - - @AssistedFactory - interface Factory { - fun create(initialState: GroupListViewState): GroupListViewModel - } - - companion object : MvRxViewModelFactory { - - @JvmStatic - override fun create(viewModelContext: ViewModelContext, state: GroupListViewState): GroupListViewModel? { - val groupListFragment: GroupListFragment = (viewModelContext as FragmentViewModelContext).fragment() - return groupListFragment.groupListViewModelFactory.create(state) - } - } - - private var currentGroupId = "" - - init { - observeGroupSummaries() - observeSelectionState() - } - - private fun observeSelectionState() { - selectSubscribe(GroupListViewState::selectedGroup) { groupSummary -> - if (groupSummary != null) { - // We only want to open group if the updated selectedGroup is a different one. - if (currentGroupId != groupSummary.groupId) { - currentGroupId = groupSummary.groupId - _viewEvents.post(GroupListViewEvents.OpenGroupSummary) - } - val optionGroup = Option.just(groupSummary) - selectedGroupStore.post(optionGroup) - } else { - // If selected group is null we force to default. It can happens when leaving the selected group. - setState { - copy(selectedGroup = this.asyncGroups()?.find { it.groupId == ALL_COMMUNITIES_GROUP_ID }) - } - } - } - } - - override fun handle(action: GroupListAction) { - when (action) { - is GroupListAction.SelectGroup -> handleSelectGroup(action) - } - } - - // PRIVATE METHODS ***************************************************************************** - - private fun handleSelectGroup(action: GroupListAction.SelectGroup) = withState { state -> - if (state.selectedGroup?.groupId != action.groupSummary.groupId) { - // We take care of refreshing group data when selecting to be sure we get all the rooms and users - viewModelScope.launch { - session.getGroup(action.groupSummary.groupId)?.fetchGroupData() - } - setState { copy(selectedGroup = action.groupSummary) } - } - } - - private fun observeGroupSummaries() { - val groupSummariesQueryParams = groupSummaryQueryParams { - memberships = listOf(Membership.JOIN) - displayName = QueryStringValue.IsNotEmpty - } - Observable.combineLatest, List>( - session - .rx() - .liveUser(session.myUserId) - .map { optionalUser -> - GroupSummary( - groupId = ALL_COMMUNITIES_GROUP_ID, - membership = Membership.JOIN, - displayName = stringProvider.getString(R.string.group_all_communities), - avatarUrl = optionalUser.getOrNull()?.avatarUrl ?: "") - }, - session - .rx() - .liveGroupSummaries(groupSummariesQueryParams), - { allCommunityGroup, communityGroups -> - listOf(allCommunityGroup) + communityGroups - } - ) - .execute { async -> - val currentSelectedGroupId = selectedGroup?.groupId - val newSelectedGroup = if (currentSelectedGroupId != null) { - async()?.find { it.groupId == currentSelectedGroupId } - } else { - async()?.firstOrNull() - } - copy(asyncGroups = async, selectedGroup = newSelectedGroup) - } - } -} diff --git a/vector/src/main/java/im/vector/app/features/grouplist/GroupSummaryController.kt b/vector/src/main/java/im/vector/app/features/grouplist/GroupSummaryController.kt deleted file mode 100644 index 03272c2729..0000000000 --- a/vector/src/main/java/im/vector/app/features/grouplist/GroupSummaryController.kt +++ /dev/null @@ -1,64 +0,0 @@ -/* - * Copyright 2019 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 im.vector.app.features.grouplist - -import com.airbnb.epoxy.EpoxyController -import im.vector.app.features.home.AvatarRenderer -import org.matrix.android.sdk.api.session.group.model.GroupSummary -import org.matrix.android.sdk.api.util.toMatrixItem -import javax.inject.Inject - -class GroupSummaryController @Inject constructor(private val avatarRenderer: AvatarRenderer) : EpoxyController() { - - var callback: Callback? = null - private var viewState: GroupListViewState? = null - - init { - requestModelBuild() - } - - fun update(viewState: GroupListViewState) { - this.viewState = viewState - requestModelBuild() - } - - override fun buildModels() { - val nonNullViewState = viewState ?: return - buildGroupModels(nonNullViewState.asyncGroups(), nonNullViewState.selectedGroup) - } - - private fun buildGroupModels(summaries: List?, selected: GroupSummary?) { - if (summaries.isNullOrEmpty()) { - return - } - summaries.forEach { groupSummary -> - val isSelected = groupSummary.groupId == selected?.groupId - groupSummaryItem { - avatarRenderer(avatarRenderer) - id(groupSummary.groupId) - matrixItem(groupSummary.toMatrixItem()) - selected(isSelected) - listener { callback?.onGroupSelected(groupSummary) } - } - } - } - - interface Callback { - fun onGroupSelected(groupSummary: GroupSummary) - } -} diff --git a/vector/src/main/java/im/vector/app/features/grouplist/HomeSpaceSummaryItem.kt b/vector/src/main/java/im/vector/app/features/grouplist/HomeSpaceSummaryItem.kt new file mode 100644 index 0000000000..553f82e98f --- /dev/null +++ b/vector/src/main/java/im/vector/app/features/grouplist/HomeSpaceSummaryItem.kt @@ -0,0 +1,68 @@ +/* + * Copyright 2019 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 im.vector.app.features.grouplist + +import android.content.res.ColorStateList +import android.widget.ImageView +import android.widget.TextView +import androidx.core.content.ContextCompat +import androidx.core.view.isVisible +import com.airbnb.epoxy.EpoxyAttribute +import com.airbnb.epoxy.EpoxyModelClass +import im.vector.app.R +import im.vector.app.core.epoxy.VectorEpoxyHolder +import im.vector.app.core.epoxy.VectorEpoxyModel +import im.vector.app.core.platform.CheckableConstraintLayout +import im.vector.app.features.home.room.list.UnreadCounterBadgeView +import im.vector.app.features.themes.ThemeUtils + +@EpoxyModelClass(layout = R.layout.item_space) +abstract class HomeSpaceSummaryItem : VectorEpoxyModel() { + + @EpoxyAttribute var selected: Boolean = false + @EpoxyAttribute var listener: (() -> Unit)? = null + @EpoxyAttribute var countState : UnreadCounterBadgeView.State = UnreadCounterBadgeView.State(0, false) + + override fun getViewType(): Int { + // mm.. it's reusing the same layout for basic space item + return R.id.space_item_home + } + + override fun bind(holder: Holder) { + super.bind(holder) + holder.rootView.setOnClickListener { listener?.invoke() } + holder.groupNameView.text = holder.view.context.getString(R.string.group_details_home) + holder.rootView.isChecked = selected + holder.rootView.context.resources + holder.avatarImageView.background = ContextCompat.getDrawable(holder.view.context, R.drawable.space_home_background) + holder.avatarImageView.setImageResource(R.drawable.ic_space_home) + holder.avatarImageView.imageTintList = ColorStateList.valueOf(ThemeUtils.getColor(holder.view.context, R.attr.riot_primary_text_color)) + holder.avatarImageView.scaleType = ImageView.ScaleType.CENTER_INSIDE + holder.leaveView.isVisible = false + + holder.counterBadgeView.render(countState) + } + + class Holder : VectorEpoxyHolder() { + val avatarImageView by bind(R.id.groupAvatarImageView) + val groupNameView by bind(R.id.groupNameView) + val rootView by bind(R.id.itemGroupLayout) + val leaveView by bind(R.id.groupTmpLeave) + val counterBadgeView by bind(R.id.groupCounterBadge) + } +} diff --git a/vector/src/main/java/im/vector/app/features/home/AvatarRenderer.kt b/vector/src/main/java/im/vector/app/features/home/AvatarRenderer.kt index 1d673a2a07..23ca5eee9c 100644 --- a/vector/src/main/java/im/vector/app/features/home/AvatarRenderer.kt +++ b/vector/src/main/java/im/vector/app/features/home/AvatarRenderer.kt @@ -26,7 +26,9 @@ import androidx.core.graphics.drawable.toBitmap import com.amulyakhare.textdrawable.TextDrawable import com.bumptech.glide.load.MultiTransformation import com.bumptech.glide.load.Transformation +import com.bumptech.glide.load.resource.bitmap.CenterCrop import com.bumptech.glide.load.resource.bitmap.CircleCrop +import com.bumptech.glide.load.resource.bitmap.RoundedCorners import com.bumptech.glide.request.RequestOptions import com.bumptech.glide.request.target.DrawableImageViewTarget import com.bumptech.glide.request.target.Target @@ -35,6 +37,7 @@ import im.vector.app.core.di.ActiveSessionHolder import im.vector.app.core.glide.GlideApp import im.vector.app.core.glide.GlideRequest import im.vector.app.core.glide.GlideRequests +import im.vector.app.core.utils.DimensionConverter import im.vector.app.features.home.room.detail.timeline.helper.MatrixItemColorProvider import jp.wasabeef.glide.transformations.BlurTransformation import jp.wasabeef.glide.transformations.ColorFilterTransformation @@ -48,7 +51,8 @@ import javax.inject.Inject */ class AvatarRenderer @Inject constructor(private val activeSessionHolder: ActiveSessionHolder, - private val matrixItemColorProvider: MatrixItemColorProvider) { + private val matrixItemColorProvider: MatrixItemColorProvider, + private val dimensionConverter: DimensionConverter) { companion object { private const val THUMBNAIL_SIZE = 250 @@ -61,6 +65,25 @@ class AvatarRenderer @Inject constructor(private val activeSessionHolder: Active DrawableImageViewTarget(imageView)) } + @UiThread + fun renderSpace(matrixItem: MatrixItem, imageView: ImageView, glideRequests: GlideRequests) { + val placeholder = getSpacePlaceholderDrawable(matrixItem) + val resolvedUrl = resolvedUrl(matrixItem.avatarUrl) + glideRequests + .load(resolvedUrl) + .transform(MultiTransformation(CenterCrop(), RoundedCorners(dimensionConverter.dpToPx(8)))) + .placeholder(placeholder) + .into(DrawableImageViewTarget(imageView)) + } + + fun renderSpace(matrixItem: MatrixItem, imageView: ImageView) { + renderSpace( + matrixItem, + imageView, + GlideApp.with(imageView) + ) + } + fun clear(imageView: ImageView) { // It can be called after recycler view is destroyed, just silently catch tryOrNull { GlideApp.with(imageView).clear(imageView) } @@ -159,6 +182,16 @@ class AvatarRenderer @Inject constructor(private val activeSessionHolder: Active .buildRound(matrixItem.firstLetterOfDisplayName(), avatarColor) } + @AnyThread + fun getSpacePlaceholderDrawable(matrixItem: MatrixItem): Drawable { + val avatarColor = matrixItemColorProvider.getColor(matrixItem) + return TextDrawable.builder() + .beginConfig() + .bold() + .endConfig() + .buildRoundRect(matrixItem.firstLetterOfDisplayName(), avatarColor, dimensionConverter.dpToPx(8)) + } + // PRIVATE API ********************************************************************************* private fun buildGlideRequest(glideRequests: GlideRequests, avatarUrl: String?): GlideRequest { diff --git a/vector/src/main/java/im/vector/app/features/grouplist/SelectedGroupDataSource.kt b/vector/src/main/java/im/vector/app/features/home/CurrentSpaceSuggestedRoomListDataSource.kt similarity index 74% rename from vector/src/main/java/im/vector/app/features/grouplist/SelectedGroupDataSource.kt rename to vector/src/main/java/im/vector/app/features/home/CurrentSpaceSuggestedRoomListDataSource.kt index 5a172e2636..21fd37c8fc 100644 --- a/vector/src/main/java/im/vector/app/features/grouplist/SelectedGroupDataSource.kt +++ b/vector/src/main/java/im/vector/app/features/home/CurrentSpaceSuggestedRoomListDataSource.kt @@ -14,13 +14,12 @@ * limitations under the License. */ -package im.vector.app.features.grouplist +package im.vector.app.features.home -import arrow.core.Option import im.vector.app.core.utils.BehaviorDataSource -import org.matrix.android.sdk.api.session.group.model.GroupSummary +import org.matrix.android.sdk.api.session.room.model.SpaceChildInfo import javax.inject.Inject import javax.inject.Singleton @Singleton -class SelectedGroupDataSource @Inject constructor() : BehaviorDataSource>(Option.empty()) +class CurrentSpaceSuggestedRoomListDataSource @Inject constructor() : BehaviorDataSource>() diff --git a/vector/src/main/java/im/vector/app/features/home/HomeActivity.kt b/vector/src/main/java/im/vector/app/features/home/HomeActivity.kt index 60a8836be5..5a7f2e2f8f 100644 --- a/vector/src/main/java/im/vector/app/features/home/HomeActivity.kt +++ b/vector/src/main/java/im/vector/app/features/home/HomeActivity.kt @@ -16,6 +16,7 @@ package im.vector.app.features.home +import android.app.Activity import android.content.Context import android.content.Intent import android.net.Uri @@ -31,11 +32,13 @@ import androidx.core.view.isVisible import androidx.drawerlayout.widget.DrawerLayout import com.airbnb.mvrx.MvRx import com.airbnb.mvrx.viewModel +import im.vector.app.AppStateHandler import im.vector.app.R import im.vector.app.core.di.ActiveSessionHolder import im.vector.app.core.di.ScreenComponent import im.vector.app.core.extensions.exhaustive import im.vector.app.core.extensions.hideKeyboard +import im.vector.app.core.extensions.registerStartForActivityResult import im.vector.app.core.extensions.replaceFragment import im.vector.app.core.platform.ToolbarConfigurable import im.vector.app.core.platform.VectorBaseActivity @@ -54,6 +57,10 @@ import im.vector.app.features.popup.VerificationVectorAlert import im.vector.app.features.rageshake.VectorUncaughtExceptionHandler import im.vector.app.features.settings.VectorPreferences import im.vector.app.features.settings.VectorSettingsActivity +import im.vector.app.features.spaces.ShareSpaceBottomSheet +import im.vector.app.features.spaces.SpaceCreationActivity +import im.vector.app.features.spaces.SpacePreviewActivity +import im.vector.app.features.spaces.SpaceSettingsMenuBottomSheet import im.vector.app.features.themes.ThemeUtils import im.vector.app.features.workers.signout.ServerBackupStatusViewModel import im.vector.app.features.workers.signout.ServerBackupStatusViewState @@ -79,6 +86,7 @@ class HomeActivity : ToolbarConfigurable, UnknownDeviceDetectorSharedViewModel.Factory, ServerBackupStatusViewModel.Factory, + UnreadMessagesSharedViewModel.Factory, NavigationInterceptor { private lateinit var sharedActionViewModel: HomeSharedActionViewModel @@ -97,9 +105,24 @@ class HomeActivity : @Inject lateinit var popupAlertManager: PopupAlertManager @Inject lateinit var shortcutsHandler: ShortcutsHandler @Inject lateinit var unknownDeviceViewModelFactory: UnknownDeviceDetectorSharedViewModel.Factory + @Inject lateinit var unreadMessagesSharedViewModelFactory: UnreadMessagesSharedViewModel.Factory @Inject lateinit var permalinkHandler: PermalinkHandler @Inject lateinit var avatarRenderer: AvatarRenderer @Inject lateinit var initSyncStepFormatter: InitSyncStepFormatter + @Inject lateinit var appStateHandler: AppStateHandler + + private val createSpaceResultLauncher = registerStartForActivityResult { activityResult -> + if (activityResult.resultCode == Activity.RESULT_OK) { + val spaceId = SpaceCreationActivity.getCreatedSpaceId(activityResult.data) + val defaultRoomId = SpaceCreationActivity.getDefaultRoomId(activityResult.data) + views.drawerLayout.closeDrawer(GravityCompat.START) + + // Here we want to change current space to the newly created one, and then immediately open the default room + if (spaceId != null) { + navigator.switchToSpace(this, spaceId, defaultRoomId, true) + } + } + } private val drawerListener = object : DrawerLayout.SimpleDrawerListener() { override fun onDrawerStateChanged(newState: Int) { @@ -121,16 +144,25 @@ class HomeActivity : return serverBackupviewModelFactory.create(initialState) } + override fun create(initialState: UnreadMessagesState): UnreadMessagesSharedViewModel { + return unreadMessagesSharedViewModelFactory.create(initialState) + } + override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) FcmHelper.ensureFcmTokenIsRetrieved(this, pushManager, vectorPreferences.areNotificationEnabledForDevice()) sharedActionViewModel = viewModelProvider.get(HomeSharedActionViewModel::class.java) views.drawerLayout.addDrawerListener(drawerListener) if (isFirstCreation()) { - replaceFragment(R.id.homeDetailFragmentContainer, LoadingFragment::class.java) + replaceFragment(R.id.homeDetailFragmentContainer, HomeDetailFragment::class.java) replaceFragment(R.id.homeDrawerFragmentContainer, HomeDrawerFragment::class.java) } +// appStateHandler.selectedRoomGroupingObservable.subscribe { +// if (supportFragmentManager.getFragment()) +// replaceFragment(R.id.homeDetailFragmentContainer, HomeDetailFragment::class.java, allowStateLoss = true) +// }.disposeOnDestroy() + sharedActionViewModel .observe() .subscribe { sharedAction -> @@ -139,7 +171,33 @@ class HomeActivity : is HomeActivitySharedAction.CloseDrawer -> views.drawerLayout.closeDrawer(GravityCompat.START) is HomeActivitySharedAction.OpenGroup -> { views.drawerLayout.closeDrawer(GravityCompat.START) - replaceFragment(R.id.homeDetailFragmentContainer, HomeDetailFragment::class.java, allowStateLoss = true) + + // Temporary + // When switching from space to group or group to space, we need to reload the fragment + // To be removed when dropping legacy groups + if (sharedAction.clearFragment) { + replaceFragment(R.id.homeDetailFragmentContainer, HomeDetailFragment::class.java, allowStateLoss = true) + } else { + // nop + } + // we might want to delay that to avoid having the drawer animation lagging + // would be probably better to let the drawer do that? in the on closed callback? + } + is HomeActivitySharedAction.OpenSpacePreview -> { + startActivity(SpacePreviewActivity.newIntent(this, sharedAction.spaceId)) + } + is HomeActivitySharedAction.AddSpace -> { + createSpaceResultLauncher.launch(SpaceCreationActivity.newIntent(this)) + } + is HomeActivitySharedAction.ShowSpaceSettings -> { + // open bottom sheet + SpaceSettingsMenuBottomSheet + .newInstance(sharedAction.spaceId, object : SpaceSettingsMenuBottomSheet.InteractionListener { + override fun onShareSpaceSelected(spaceId: String) { + ShareSpaceBottomSheet.show(supportFragmentManager, spaceId) + } + }) + .show(supportFragmentManager, "SPACE_SETTINGS") } }.exhaustive } @@ -428,6 +486,23 @@ class HomeActivity : return true } + override fun navToRoom(roomId: String?, eventId: String?, deepLink: Uri?): Boolean { + if (roomId == null) return false + val listener = object : MatrixToBottomSheet.InteractionListener { + override fun navigateToRoom(roomId: String) { + navigator.openRoom(this@HomeActivity, roomId) + } + + override fun switchToSpace(spaceId: String) { + navigator.switchToSpace(this@HomeActivity, spaceId, null, false) + } + } + + MatrixToBottomSheet.withLink(deepLink.toString(), listener) + .show(supportFragmentManager, "HA#MatrixToBottomSheet") + return true + } + companion object { fun newIntent(context: Context, clearNotification: Boolean = false, accountCreation: Boolean = false): Intent { val args = HomeActivityArgs( diff --git a/vector/src/main/java/im/vector/app/features/home/HomeActivitySharedAction.kt b/vector/src/main/java/im/vector/app/features/home/HomeActivitySharedAction.kt index 52b3c58785..db0a9ba9eb 100644 --- a/vector/src/main/java/im/vector/app/features/home/HomeActivitySharedAction.kt +++ b/vector/src/main/java/im/vector/app/features/home/HomeActivitySharedAction.kt @@ -24,5 +24,8 @@ import im.vector.app.core.platform.VectorSharedAction sealed class HomeActivitySharedAction : VectorSharedAction { object OpenDrawer : HomeActivitySharedAction() object CloseDrawer : HomeActivitySharedAction() - object OpenGroup : HomeActivitySharedAction() + data class OpenGroup(val clearFragment: Boolean) : HomeActivitySharedAction() + object AddSpace : HomeActivitySharedAction() + data class OpenSpacePreview(val spaceId: String) : HomeActivitySharedAction() + data class ShowSpaceSettings(val spaceId: String) : HomeActivitySharedAction() } diff --git a/vector/src/main/java/im/vector/app/features/home/HomeDetailFragment.kt b/vector/src/main/java/im/vector/app/features/home/HomeDetailFragment.kt index 5def43b60b..69395b2386 100644 --- a/vector/src/main/java/im/vector/app/features/home/HomeDetailFragment.kt +++ b/vector/src/main/java/im/vector/app/features/home/HomeDetailFragment.kt @@ -23,14 +23,15 @@ import android.view.MenuItem import android.view.View import android.view.ViewGroup import androidx.core.content.ContextCompat +import androidx.core.view.isVisible import com.airbnb.mvrx.activityViewModel import com.airbnb.mvrx.fragmentViewModel import com.airbnb.mvrx.withState import com.google.android.material.badge.BadgeDrawable import im.vector.app.R +import im.vector.app.RoomGroupingMethod import im.vector.app.core.extensions.commitTransaction import im.vector.app.core.extensions.toMvRxBundle -import im.vector.app.core.glide.GlideApp import im.vector.app.core.platform.ToolbarConfigurable import im.vector.app.core.platform.VectorBaseActivity import im.vector.app.core.platform.VectorBaseFragment @@ -43,6 +44,7 @@ import im.vector.app.features.call.VectorCallActivity import im.vector.app.features.call.webrtc.WebRtcCallManager import im.vector.app.features.home.room.list.RoomListFragment import im.vector.app.features.home.room.list.RoomListParams +import im.vector.app.features.home.room.list.UnreadCounterBadgeView import im.vector.app.features.popup.PopupAlertManager import im.vector.app.features.popup.VerificationVectorAlert import im.vector.app.features.settings.VectorPreferences @@ -52,15 +54,10 @@ import im.vector.app.features.workers.signout.BannerState import im.vector.app.features.workers.signout.ServerBackupStatusViewModel import im.vector.app.features.workers.signout.ServerBackupStatusViewState import org.matrix.android.sdk.api.session.group.model.GroupSummary -import org.matrix.android.sdk.api.util.toMatrixItem +import org.matrix.android.sdk.api.session.room.model.RoomSummary import org.matrix.android.sdk.internal.crypto.model.rest.DeviceInfo -import timber.log.Timber import javax.inject.Inject -private const val INDEX_PEOPLE = 0 -private const val INDEX_ROOMS = 1 -private const val INDEX_CATCHUP = 2 - class HomeDetailFragment @Inject constructor( val homeDetailViewModelFactory: HomeDetailViewModel.Factory, private val serverBackupStatusViewModelFactory: ServerBackupStatusViewModel.Factory, @@ -75,6 +72,7 @@ class HomeDetailFragment @Inject constructor( private val viewModel: HomeDetailViewModel by fragmentViewModel() private val unknownDeviceDetectorSharedViewModel: UnknownDeviceDetectorSharedViewModel by activityViewModel() + private val unreadMessagesSharedViewModel: UnreadMessagesSharedViewModel by activityViewModel() private val serverBackupStatusViewModel: ServerBackupStatusViewModel by activityViewModel() private lateinit var sharedActionViewModel: HomeSharedActionViewModel @@ -127,9 +125,17 @@ class HomeDetailFragment @Inject constructor( views.bottomNavigationView.selectedItemId = it.displayMode.toMenuId() } - viewModel.selectSubscribe(this, HomeDetailViewState::groupSummary) { groupSummary -> - onGroupChange(groupSummary.orNull()) + viewModel.selectSubscribe(this, HomeDetailViewState::roomGroupingMethod) { roomGroupingMethod -> + when (roomGroupingMethod) { + is RoomGroupingMethod.ByLegacyGroup -> { + onGroupChange(roomGroupingMethod.groupSummary) + } + is RoomGroupingMethod.BySpace -> { + onSpaceChange(roomGroupingMethod.spaceSummary) + } + } } + viewModel.selectSubscribe(this, HomeDetailViewState::displayMode) { displayMode -> switchDisplayMode(displayMode) } @@ -152,6 +158,15 @@ class HomeDetailFragment @Inject constructor( } } + unreadMessagesSharedViewModel.subscribe { state -> + views.drawerUnreadCounterBadgeView.render( + UnreadCounterBadgeView.State( + count = state.otherSpacesUnread.totalCount, + highlighted = state.otherSpacesUnread.isHighlight + ) + ) + } + sharedCallActionViewModel .liveKnownCalls .observe(viewLifecycleOwner, { @@ -237,9 +252,20 @@ class HomeDetailFragment @Inject constructor( } private fun onGroupChange(groupSummary: GroupSummary?) { - groupSummary?.let { - // Use GlideApp with activity context to avoid the glideRequests to be paused - avatarRenderer.render(it.toMatrixItem(), views.groupToolbarAvatarImageView, GlideApp.with(requireActivity())) + if (groupSummary == null) { + views.groupToolbarSpaceTitleView.isVisible = false + } else { + views.groupToolbarSpaceTitleView.isVisible = true + views.groupToolbarSpaceTitleView.text = groupSummary.displayName + } + } + + private fun onSpaceChange(spaceSummary: RoomSummary?) { + if (spaceSummary == null) { + views.groupToolbarSpaceTitleView.isVisible = false + } else { + views.groupToolbarSpaceTitleView.isVisible = true + views.groupToolbarSpaceTitleView.text = spaceSummary.displayName } } @@ -247,10 +273,10 @@ class HomeDetailFragment @Inject constructor( serverBackupStatusViewModel .subscribe(this) { when (val banState = it.bannerState.invoke()) { - is BannerState.Setup -> views.homeKeysBackupBanner.render(KeysBackupBanner.State.Setup(banState.numberOfKeys), false) + is BannerState.Setup -> views.homeKeysBackupBanner.render(KeysBackupBanner.State.Setup(banState.numberOfKeys), false) BannerState.BackingUp -> views.homeKeysBackupBanner.render(KeysBackupBanner.State.BackingUp, false) null, - BannerState.Hidden -> views.homeKeysBackupBanner.render(KeysBackupBanner.State.Hidden, false) + BannerState.Hidden -> views.homeKeysBackupBanner.render(KeysBackupBanner.State.Hidden, false) } } views.homeKeysBackupBanner.delegate = this @@ -274,6 +300,21 @@ class HomeDetailFragment @Inject constructor( views.groupToolbarAvatarImageView.debouncedClicks { sharedActionViewModel.post(HomeActivitySharedAction.OpenDrawer) } + + views.homeToolbarContent.debouncedClicks { + withState(viewModel) { + when (it.roomGroupingMethod) { + is RoomGroupingMethod.ByLegacyGroup -> { + // nothing do far + } + is RoomGroupingMethod.BySpace -> { + it.roomGroupingMethod.spaceSummary?.let { + sharedActionViewModel.post(HomeActivitySharedAction.ShowSpaceSettings(it.roomId)) + } + } + } + } + } } private fun setupBottomNavigationView() { @@ -281,7 +322,7 @@ class HomeDetailFragment @Inject constructor( views.bottomNavigationView.setOnNavigationItemSelectedListener { val displayMode = when (it.itemId) { R.id.bottom_action_people -> RoomListDisplayMode.PEOPLE - R.id.bottom_action_rooms -> RoomListDisplayMode.ROOMS + R.id.bottom_action_rooms -> RoomListDisplayMode.ROOMS else -> RoomListDisplayMode.NOTIFICATIONS } viewModel.handle(HomeDetailAction.SwitchDisplayMode(displayMode)) @@ -336,7 +377,7 @@ class HomeDetailFragment @Inject constructor( } override fun invalidate() = withState(viewModel) { - Timber.v(it.toString()) +// Timber.v(it.toString()) views.bottomNavigationView.getOrCreateBadge(R.id.bottom_action_people).render(it.notificationCountPeople, it.notificationHighlightPeople) views.bottomNavigationView.getOrCreateBadge(R.id.bottom_action_rooms).render(it.notificationCountRooms, it.notificationHighlightRooms) views.bottomNavigationView.getOrCreateBadge(R.id.bottom_action_notification).render(it.notificationCountCatchup, it.notificationHighlightCatchup) @@ -359,7 +400,7 @@ class HomeDetailFragment @Inject constructor( private fun RoomListDisplayMode.toMenuId() = when (this) { RoomListDisplayMode.PEOPLE -> R.id.bottom_action_people - RoomListDisplayMode.ROOMS -> R.id.bottom_action_rooms + RoomListDisplayMode.ROOMS -> R.id.bottom_action_rooms else -> R.id.bottom_action_notification } diff --git a/vector/src/main/java/im/vector/app/features/home/HomeDetailViewModel.kt b/vector/src/main/java/im/vector/app/features/home/HomeDetailViewModel.kt index d6a8b075f4..91f3877fab 100644 --- a/vector/src/main/java/im/vector/app/features/home/HomeDetailViewModel.kt +++ b/vector/src/main/java/im/vector/app/features/home/HomeDetailViewModel.kt @@ -23,18 +23,22 @@ import com.airbnb.mvrx.ViewModelContext import dagger.assisted.Assisted import dagger.assisted.AssistedFactory import dagger.assisted.AssistedInject +import im.vector.app.AppStateHandler +import im.vector.app.RoomGroupingMethod import im.vector.app.core.di.HasScreenInjector import im.vector.app.core.platform.EmptyViewEvents import im.vector.app.core.platform.VectorViewModel -import im.vector.app.core.resources.StringProvider -import im.vector.app.features.grouplist.SelectedGroupDataSource import im.vector.app.features.ui.UiStateRepository +import io.reactivex.schedulers.Schedulers import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.launch +import org.matrix.android.sdk.api.query.ActiveSpaceFilter import org.matrix.android.sdk.api.query.RoomCategoryFilter import org.matrix.android.sdk.api.session.Session +import org.matrix.android.sdk.api.session.room.RoomSortOrder import org.matrix.android.sdk.api.session.room.model.Membership import org.matrix.android.sdk.api.session.room.roomSummaryQueryParams +import org.matrix.android.sdk.api.util.toMatrixItem import org.matrix.android.sdk.rx.asObservable import org.matrix.android.sdk.rx.rx import timber.log.Timber @@ -47,8 +51,7 @@ import java.util.concurrent.TimeUnit class HomeDetailViewModel @AssistedInject constructor(@Assisted initialState: HomeDetailViewState, private val session: Session, private val uiStateRepository: UiStateRepository, - private val selectedGroupStore: SelectedGroupDataSource, - private val stringProvider: StringProvider) + private val appStateHandler: AppStateHandler) : VectorViewModel(initialState) { @AssistedFactory @@ -74,14 +77,20 @@ class HomeDetailViewModel @AssistedInject constructor(@Assisted initialState: Ho init { observeSyncState() - observeSelectedGroupStore() + observeRoomGroupingMethod() observeRoomSummaries() + + session.rx().liveUser(session.myUserId).execute { + copy( + myMatrixItem = it.invoke()?.getOrNull()?.toMatrixItem() + ) + } } override fun handle(action: HomeDetailAction) { when (action) { is HomeDetailAction.SwitchDisplayMode -> handleSwitchDisplayMode(action) - HomeDetailAction.MarkAllRoomsRead -> handleMarkAllRoomsRead() + HomeDetailAction.MarkAllRoomsRead -> handleMarkAllRoomsRead() } } @@ -126,64 +135,78 @@ class HomeDetailViewModel @AssistedInject constructor(@Assisted initialState: Ho .disposeOnClear() } - private fun observeSelectedGroupStore() { - selectedGroupStore - .observe() + private fun observeRoomGroupingMethod() { + appStateHandler.selectedRoomGroupingObservable .subscribe { - setState { - copy(groupSummary = it) - } + setState { + copy( + roomGroupingMethod = it.orNull() ?: RoomGroupingMethod.BySpace(null) + ) + } } .disposeOnClear() } private fun observeRoomSummaries() { - session.getPagedRoomSummariesLive( - roomSummaryQueryParams { - memberships = Membership.activeMemberships() - } - ) - .asObservable() + appStateHandler.selectedRoomGroupingObservable.distinctUntilChanged().switchMap { + // we use it as a trigger to all changes in room, but do not really load + // the actual models + session.getPagedRoomSummariesLive( + roomSummaryQueryParams { + memberships = Membership.activeMemberships() + }, + sortOrder = RoomSortOrder.NONE + ).asObservable() + } + .observeOn(Schedulers.computation()) .throttleFirst(300, TimeUnit.MILLISECONDS) .subscribe { - val dmInvites = session.getRoomSummaries( - roomSummaryQueryParams { - memberships = listOf(Membership.INVITE) - roomCategoryFilter = RoomCategoryFilter.ONLY_DM - } - ).size + when (val groupingMethod = appStateHandler.getCurrentRoomGroupingMethod()) { + is RoomGroupingMethod.ByLegacyGroup -> { + // TODO!! + } + is RoomGroupingMethod.BySpace -> { + val dmInvites = session.getRoomSummaries( + roomSummaryQueryParams { + memberships = listOf(Membership.INVITE) + roomCategoryFilter = RoomCategoryFilter.ONLY_DM + } + ).size - val roomsInvite = session.getRoomSummaries( - roomSummaryQueryParams { - memberships = listOf(Membership.INVITE) - roomCategoryFilter = RoomCategoryFilter.ONLY_ROOMS - } - ).size + val roomsInvite = session.getRoomSummaries( + roomSummaryQueryParams { + memberships = listOf(Membership.INVITE) + roomCategoryFilter = RoomCategoryFilter.ONLY_ROOMS + } + ).size - val dmRooms = session.getNotificationCountForRooms( - roomSummaryQueryParams { - memberships = listOf(Membership.JOIN) - roomCategoryFilter = RoomCategoryFilter.ONLY_DM - } - ) + val dmRooms = session.getNotificationCountForRooms( + roomSummaryQueryParams { + memberships = listOf(Membership.JOIN) + roomCategoryFilter = RoomCategoryFilter.ONLY_DM + } + ) - val otherRooms = session.getNotificationCountForRooms( - roomSummaryQueryParams { - memberships = listOf(Membership.JOIN) - roomCategoryFilter = RoomCategoryFilter.ONLY_ROOMS - } - ) + val otherRooms = session.getNotificationCountForRooms( + roomSummaryQueryParams { + memberships = listOf(Membership.JOIN) + roomCategoryFilter = RoomCategoryFilter.ONLY_ROOMS + activeSpaceId = ActiveSpaceFilter.ActiveSpace(groupingMethod.spaceSummary?.roomId) + } + ) - setState { - copy( - notificationCountCatchup = dmRooms.totalCount + otherRooms.totalCount + roomsInvite + dmInvites, - notificationHighlightCatchup = dmRooms.isHighlight || otherRooms.isHighlight, - notificationCountPeople = dmRooms.totalCount + dmInvites, - notificationHighlightPeople = dmRooms.isHighlight || dmInvites > 0, - notificationCountRooms = otherRooms.totalCount + roomsInvite, - notificationHighlightRooms = otherRooms.isHighlight || roomsInvite > 0, - hasUnreadMessages = dmRooms.totalCount + otherRooms.totalCount > 0 - ) + setState { + copy( + notificationCountCatchup = dmRooms.totalCount + otherRooms.totalCount + roomsInvite + dmInvites, + notificationHighlightCatchup = dmRooms.isHighlight || otherRooms.isHighlight, + notificationCountPeople = dmRooms.totalCount + dmInvites, + notificationHighlightPeople = dmRooms.isHighlight || dmInvites > 0, + notificationCountRooms = otherRooms.totalCount + roomsInvite, + notificationHighlightRooms = otherRooms.isHighlight || roomsInvite > 0, + hasUnreadMessages = dmRooms.totalCount + otherRooms.totalCount > 0 + ) + } + } } } .disposeOnClear() diff --git a/vector/src/main/java/im/vector/app/features/home/HomeDetailViewState.kt b/vector/src/main/java/im/vector/app/features/home/HomeDetailViewState.kt index 533c9166f9..5aa9612a7a 100644 --- a/vector/src/main/java/im/vector/app/features/home/HomeDetailViewState.kt +++ b/vector/src/main/java/im/vector/app/features/home/HomeDetailViewState.kt @@ -16,16 +16,17 @@ package im.vector.app.features.home -import arrow.core.Option import com.airbnb.mvrx.Async import com.airbnb.mvrx.MvRxState import com.airbnb.mvrx.Uninitialized -import org.matrix.android.sdk.api.session.group.model.GroupSummary +import im.vector.app.RoomGroupingMethod import org.matrix.android.sdk.api.session.room.model.RoomSummary import org.matrix.android.sdk.api.session.sync.SyncState +import org.matrix.android.sdk.api.util.MatrixItem data class HomeDetailViewState( - val groupSummary: Option = Option.empty(), + val roomGroupingMethod: RoomGroupingMethod = RoomGroupingMethod.BySpace(null), + val myMatrixItem: MatrixItem? = null, val asyncRooms: Async> = Uninitialized, val displayMode: RoomListDisplayMode = RoomListDisplayMode.PEOPLE, val notificationCountCatchup: Int = 0, diff --git a/vector/src/main/java/im/vector/app/features/home/HomeDrawerFragment.kt b/vector/src/main/java/im/vector/app/features/home/HomeDrawerFragment.kt index 59eb45607e..9ff865b9d1 100644 --- a/vector/src/main/java/im/vector/app/features/home/HomeDrawerFragment.kt +++ b/vector/src/main/java/im/vector/app/features/home/HomeDrawerFragment.kt @@ -30,9 +30,10 @@ import im.vector.app.core.extensions.replaceChildFragment import im.vector.app.core.platform.VectorBaseFragment import im.vector.app.core.utils.startSharePlainTextIntent import im.vector.app.databinding.FragmentHomeDrawerBinding -import im.vector.app.features.grouplist.GroupListFragment +// import im.vector.app.features.grouplist.GroupListFragment import im.vector.app.features.settings.VectorPreferences import im.vector.app.features.settings.VectorSettingsActivity +import im.vector.app.features.spaces.SpaceListFragment import im.vector.app.features.usercode.UserCodeActivity import im.vector.app.features.workers.signout.SignOutUiWorker @@ -58,7 +59,7 @@ class HomeDrawerFragment @Inject constructor( sharedActionViewModel = activityViewModelProvider.get(HomeSharedActionViewModel::class.java) if (savedInstanceState == null) { - replaceChildFragment(R.id.homeDrawerGroupListContainer, GroupListFragment::class.java) + replaceChildFragment(R.id.homeDrawerGroupListContainer, SpaceListFragment::class.java) } session.getUserLive(session.myUserId).observeK(viewLifecycleOwner) { optionalUser -> val user = optionalUser?.getOrNull() diff --git a/vector/src/main/java/im/vector/app/features/home/UnreadMessagesSharedViewModel.kt b/vector/src/main/java/im/vector/app/features/home/UnreadMessagesSharedViewModel.kt new file mode 100644 index 0000000000..77ffe903fc --- /dev/null +++ b/vector/src/main/java/im/vector/app/features/home/UnreadMessagesSharedViewModel.kt @@ -0,0 +1,167 @@ +/* + * Copyright (c) 2021 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 im.vector.app.features.home + +import com.airbnb.mvrx.ActivityViewModelContext +import com.airbnb.mvrx.FragmentViewModelContext +import com.airbnb.mvrx.MvRxState +import com.airbnb.mvrx.MvRxViewModelFactory +import com.airbnb.mvrx.ViewModelContext +import dagger.assisted.Assisted +import dagger.assisted.AssistedFactory +import dagger.assisted.AssistedInject +import im.vector.app.AppStateHandler +import im.vector.app.RoomGroupingMethod +import im.vector.app.core.platform.EmptyAction +import im.vector.app.core.platform.EmptyViewEvents +import im.vector.app.core.platform.VectorViewModel +import io.reactivex.Observable +import io.reactivex.schedulers.Schedulers +import org.matrix.android.sdk.api.query.ActiveSpaceFilter +import org.matrix.android.sdk.api.session.Session +import org.matrix.android.sdk.api.session.room.RoomSortOrder +import org.matrix.android.sdk.api.session.room.model.Membership +import org.matrix.android.sdk.api.session.room.roomSummaryQueryParams +import org.matrix.android.sdk.api.session.room.summary.RoomAggregateNotificationCount +import org.matrix.android.sdk.rx.asObservable +import java.util.concurrent.TimeUnit + +data class UnreadMessagesState( + val homeSpaceUnread: RoomAggregateNotificationCount = RoomAggregateNotificationCount(0, 0), + val otherSpacesUnread: RoomAggregateNotificationCount = RoomAggregateNotificationCount(0, 0) +) : MvRxState + +data class CountInfo( + val homeCount: RoomAggregateNotificationCount, + val otherCount: RoomAggregateNotificationCount +) + +class UnreadMessagesSharedViewModel @AssistedInject constructor(@Assisted initialState: UnreadMessagesState, + session: Session, + appStateHandler: AppStateHandler) + : VectorViewModel(initialState) { + + @AssistedFactory + interface Factory { + fun create(initialState: UnreadMessagesState): UnreadMessagesSharedViewModel + } + + companion object : MvRxViewModelFactory { + + @JvmStatic + override fun create(viewModelContext: ViewModelContext, state: UnreadMessagesState): UnreadMessagesSharedViewModel? { + val factory = when (viewModelContext) { + is FragmentViewModelContext -> viewModelContext.fragment as? Factory + is ActivityViewModelContext -> viewModelContext.activity as? Factory + } + return factory?.create(state) ?: error("You should let your activity/fragment implements Factory interface") + } + } + + override fun handle(action: EmptyAction) {} + + init { + + session.getPagedRoomSummariesLive( + roomSummaryQueryParams { + this.memberships = listOf(Membership.JOIN) + this.activeSpaceId = ActiveSpaceFilter.ActiveSpace(null) + }, sortOrder = RoomSortOrder.NONE + ).asObservable() + .throttleFirst(300, TimeUnit.MILLISECONDS) + .execute { + val counts = session.getNotificationCountForRooms( + roomSummaryQueryParams { + this.memberships = listOf(Membership.JOIN) + this.activeSpaceId = ActiveSpaceFilter.ActiveSpace(null) + } + ) + val invites = session.getRoomSummaries( + roomSummaryQueryParams { + this.memberships = listOf(Membership.INVITE) + this.activeSpaceId = ActiveSpaceFilter.ActiveSpace(null) + } + ).size + copy( + homeSpaceUnread = RoomAggregateNotificationCount( + counts.notificationCount + invites, + highlightCount = counts.highlightCount + invites + ) + ) + } + + Observable.combineLatest( + appStateHandler.selectedRoomGroupingObservable.distinctUntilChanged(), + appStateHandler.selectedRoomGroupingObservable.switchMap { + session.getPagedRoomSummariesLive( + roomSummaryQueryParams { + this.memberships = Membership.activeMemberships() + }, sortOrder = RoomSortOrder.NONE + ).asObservable() + .throttleFirst(300, TimeUnit.MILLISECONDS) + .observeOn(Schedulers.computation()) + }, + { groupingMethod, _ -> + when (groupingMethod.orNull()) { + is RoomGroupingMethod.ByLegacyGroup -> { + // currently not supported + CountInfo( + RoomAggregateNotificationCount(0, 0), + RoomAggregateNotificationCount(0, 0) + ) + } + is RoomGroupingMethod.BySpace -> { + val selectedSpace = appStateHandler.safeActiveSpaceId() + val counts = session.getNotificationCountForRooms( + roomSummaryQueryParams { + this.memberships = listOf(Membership.JOIN) + this.activeSpaceId = ActiveSpaceFilter.ActiveSpace(null) + } + ) + val rootCounts = session.spaceService().getRootSpaceSummaries() + .filter { + // filter out current selection + it.roomId != selectedSpace + } + CountInfo( + homeCount = counts, + otherCount = RoomAggregateNotificationCount( + rootCounts.fold(0, { acc, rs -> + acc + rs.notificationCount + }) + (counts.notificationCount.takeIf { selectedSpace != null } ?: 0), + rootCounts.fold(0, { acc, rs -> + acc + rs.highlightCount + }) + (counts.highlightCount.takeIf { selectedSpace != null } ?: 0) + ) + ) + } + null -> { + CountInfo( + RoomAggregateNotificationCount(0, 0), + RoomAggregateNotificationCount(0, 0) + ) + } + } + } + ).execute { + copy( + homeSpaceUnread = it.invoke()?.homeCount ?: RoomAggregateNotificationCount(0, 0), + otherSpacesUnread = it.invoke()?.otherCount ?: RoomAggregateNotificationCount(0, 0) + ) + } + } +} diff --git a/vector/src/main/java/im/vector/app/features/home/room/detail/RoomDetailFragment.kt b/vector/src/main/java/im/vector/app/features/home/room/detail/RoomDetailFragment.kt index 13e9fb18b0..699906b2c4 100644 --- a/vector/src/main/java/im/vector/app/features/home/room/detail/RoomDetailFragment.kt +++ b/vector/src/main/java/im/vector/app/features/home/room/detail/RoomDetailFragment.kt @@ -91,11 +91,11 @@ import im.vector.app.core.intent.getMimeTypeFromUri import im.vector.app.core.platform.VectorBaseFragment import im.vector.app.core.platform.showOptimizedSnackbar import im.vector.app.core.resources.ColorProvider -import im.vector.app.core.ui.views.CurrentCallsView -import im.vector.app.core.ui.views.KnownCallsViewHolder import im.vector.app.core.ui.views.ActiveConferenceView -import im.vector.app.core.ui.views.FailedMessagesWarningView +import im.vector.app.core.ui.views.CurrentCallsView import im.vector.app.core.ui.views.JumpToReadMarkerView +import im.vector.app.core.ui.views.KnownCallsViewHolder +import im.vector.app.core.ui.views.FailedMessagesWarningView import im.vector.app.core.ui.views.NotificationAreaView import im.vector.app.core.utils.Debouncer import im.vector.app.core.utils.DimensionConverter @@ -164,6 +164,7 @@ import im.vector.app.features.roomprofile.RoomProfileActivity import im.vector.app.features.settings.VectorPreferences import im.vector.app.features.settings.VectorSettingsActivity import im.vector.app.features.share.SharedData +import im.vector.app.features.spaces.ShareSpaceBottomSheet import im.vector.app.features.themes.ThemeUtils import im.vector.app.features.widgets.WidgetActivity import im.vector.app.features.widgets.WidgetArgs @@ -212,7 +213,8 @@ import javax.inject.Inject data class RoomDetailArgs( val roomId: String, val eventId: String? = null, - val sharedData: SharedData? = null + val sharedData: SharedData? = null, + val openShareSpaceForId: String? = null ) : Parcelable class RoomDetailFragment @Inject constructor( @@ -295,7 +297,7 @@ class RoomDetailFragment @Inject constructor( private lateinit var attachmentsHelper: AttachmentsHelper private lateinit var keyboardStateUtils: KeyboardStateUtils - private lateinit var callActionsHandler : StartCallActionsHandler + private lateinit var callActionsHandler: StartCallActionsHandler private lateinit var attachmentTypeSelector: AttachmentTypeSelectorView @@ -409,6 +411,7 @@ class RoomDetailFragment @Inject constructor( if (savedInstanceState == null) { handleShareData() + handleSpaceShare() } } @@ -421,7 +424,7 @@ class RoomDetailFragment @Inject constructor( startActivity(intent) } - private fun handleChatEffect(chatEffect: ChatEffect) { + private fun handleChatEffect(chatEffect: ChatEffect) { when (chatEffect) { ChatEffect.CONFETTI -> { views.viewKonfetti.isVisible = true @@ -673,6 +676,15 @@ class RoomDetailFragment @Inject constructor( }.exhaustive } + private fun handleSpaceShare() { + roomDetailArgs.openShareSpaceForId?.let { spaceId -> + ShareSpaceBottomSheet.show(childFragmentManager, spaceId) + view?.post { + handleChatEffect(ChatEffect.CONFETTI) + } + } + } + override fun onDestroyView() { timelineEventController.callback = null timelineEventController.removeModelBuildListener(modelBuildListener) @@ -838,7 +850,7 @@ class RoomDetailFragment @Inject constructor( roomDetailViewModel.handle(RoomDetailAction.ManageIntegrations) true } - R.id.voice_call -> { + R.id.voice_call -> { callActionsHandler.onVoiceCallClicked() true } @@ -1466,7 +1478,7 @@ class RoomDetailFragment @Inject constructor( override fun onUrlClicked(url: String, title: String): Boolean { permalinkHandler .launch(requireActivity(), url, object : NavigationInterceptor { - override fun navToRoom(roomId: String?, eventId: String?): Boolean { + override fun navToRoom(roomId: String?, eventId: String?, deepLink: Uri?): Boolean { // Same room? if (roomId == roomDetailArgs.roomId) { // Navigation to same room @@ -1674,7 +1686,7 @@ class RoomDetailFragment @Inject constructor( override fun onRoomCreateLinkClicked(url: String) { permalinkHandler .launch(requireContext(), url, object : NavigationInterceptor { - override fun navToRoom(roomId: String?, eventId: String?): Boolean { + override fun navToRoom(roomId: String?, eventId: String?, deepLink: Uri?): Boolean { requireActivity().finish() return false } diff --git a/vector/src/main/java/im/vector/app/features/home/room/detail/RoomDetailViewModel.kt b/vector/src/main/java/im/vector/app/features/home/room/detail/RoomDetailViewModel.kt index e21428921f..13cb27efaf 100644 --- a/vector/src/main/java/im/vector/app/features/home/room/detail/RoomDetailViewModel.kt +++ b/vector/src/main/java/im/vector/app/features/home/room/detail/RoomDetailViewModel.kt @@ -42,8 +42,8 @@ import im.vector.app.features.call.dialpad.DialPadLookup import im.vector.app.features.call.webrtc.WebRtcCallManager import im.vector.app.features.command.CommandParser import im.vector.app.features.command.ParsedCommand -import im.vector.app.features.crypto.keysrequest.OutboundSessionKeySharingStrategy import im.vector.app.features.createdirect.DirectRoomHelper +import im.vector.app.features.crypto.keysrequest.OutboundSessionKeySharingStrategy import im.vector.app.features.crypto.verification.SupportedVerificationMethodsProvider import im.vector.app.features.home.room.detail.composer.rainbow.RainbowGenerator import im.vector.app.features.home.room.detail.sticker.StickerPickerActionHandler @@ -98,6 +98,7 @@ import org.matrix.android.sdk.api.session.room.timeline.TimelineEvent import org.matrix.android.sdk.api.session.room.timeline.getLastMessageContent import org.matrix.android.sdk.api.session.room.timeline.getRelationContent import org.matrix.android.sdk.api.session.room.timeline.getTextEditableContent +import org.matrix.android.sdk.api.session.space.CreateSpaceParams import org.matrix.android.sdk.api.session.widgets.model.WidgetType import org.matrix.android.sdk.api.util.appendParamToUrl import org.matrix.android.sdk.api.util.toOptional @@ -182,14 +183,14 @@ class RoomDetailViewModel @AssistedInject constructor( observePowerLevel() updateShowDialerOptionState() room.getRoomSummaryLive() - viewModelScope.launch { + viewModelScope.launch(Dispatchers.IO) { tryOrNull { room.markAsRead(ReadService.MarkAsReadParams.READ_RECEIPT) } } // Inform the SDK that the room is displayed - viewModelScope.launch { + viewModelScope.launch(Dispatchers.IO) { try { session.onRoomDisplayed(initialState.roomId) - } catch (_: Exception) { + } catch (_: Throwable) { } } callManager.addPstnSupportListener(this) @@ -270,68 +271,68 @@ class RoomDetailViewModel @AssistedInject constructor( override fun handle(action: RoomDetailAction) { when (action) { - is RoomDetailAction.UserIsTyping -> handleUserIsTyping(action) - is RoomDetailAction.ComposerFocusChange -> handleComposerFocusChange(action) - is RoomDetailAction.SaveDraft -> handleSaveDraft(action) - is RoomDetailAction.SendMessage -> handleSendMessage(action) - is RoomDetailAction.SendMedia -> handleSendMedia(action) - is RoomDetailAction.SendSticker -> handleSendSticker(action) - is RoomDetailAction.TimelineEventTurnsVisible -> handleEventVisible(action) - is RoomDetailAction.TimelineEventTurnsInvisible -> handleEventInvisible(action) - is RoomDetailAction.LoadMoreTimelineEvents -> handleLoadMore(action) - is RoomDetailAction.SendReaction -> handleSendReaction(action) - is RoomDetailAction.AcceptInvite -> handleAcceptInvite() - is RoomDetailAction.RejectInvite -> handleRejectInvite() - is RoomDetailAction.RedactAction -> handleRedactEvent(action) - is RoomDetailAction.UndoReaction -> handleUndoReact(action) - is RoomDetailAction.UpdateQuickReactAction -> handleUpdateQuickReaction(action) - is RoomDetailAction.EnterRegularMode -> handleEnterRegularMode(action) - is RoomDetailAction.EnterEditMode -> handleEditAction(action) - is RoomDetailAction.EnterQuoteMode -> handleQuoteAction(action) - is RoomDetailAction.EnterReplyMode -> handleReplyAction(action) - is RoomDetailAction.DownloadOrOpen -> handleOpenOrDownloadFile(action) - is RoomDetailAction.NavigateToEvent -> handleNavigateToEvent(action) - is RoomDetailAction.HandleTombstoneEvent -> handleTombstoneEvent(action) - is RoomDetailAction.ResendMessage -> handleResendEvent(action) - is RoomDetailAction.RemoveFailedEcho -> handleRemove(action) - is RoomDetailAction.ResendAll -> handleResendAll() - is RoomDetailAction.MarkAllAsRead -> handleMarkAllAsRead() - is RoomDetailAction.ReportContent -> handleReportContent(action) - is RoomDetailAction.IgnoreUser -> handleIgnoreUser(action) + is RoomDetailAction.UserIsTyping -> handleUserIsTyping(action) + is RoomDetailAction.ComposerFocusChange -> handleComposerFocusChange(action) + is RoomDetailAction.SaveDraft -> handleSaveDraft(action) + is RoomDetailAction.SendMessage -> handleSendMessage(action) + is RoomDetailAction.SendMedia -> handleSendMedia(action) + is RoomDetailAction.SendSticker -> handleSendSticker(action) + is RoomDetailAction.TimelineEventTurnsVisible -> handleEventVisible(action) + is RoomDetailAction.TimelineEventTurnsInvisible -> handleEventInvisible(action) + is RoomDetailAction.LoadMoreTimelineEvents -> handleLoadMore(action) + is RoomDetailAction.SendReaction -> handleSendReaction(action) + is RoomDetailAction.AcceptInvite -> handleAcceptInvite() + is RoomDetailAction.RejectInvite -> handleRejectInvite() + is RoomDetailAction.RedactAction -> handleRedactEvent(action) + is RoomDetailAction.UndoReaction -> handleUndoReact(action) + is RoomDetailAction.UpdateQuickReactAction -> handleUpdateQuickReaction(action) + is RoomDetailAction.EnterRegularMode -> handleEnterRegularMode(action) + is RoomDetailAction.EnterEditMode -> handleEditAction(action) + is RoomDetailAction.EnterQuoteMode -> handleQuoteAction(action) + is RoomDetailAction.EnterReplyMode -> handleReplyAction(action) + is RoomDetailAction.DownloadOrOpen -> handleOpenOrDownloadFile(action) + is RoomDetailAction.NavigateToEvent -> handleNavigateToEvent(action) + is RoomDetailAction.HandleTombstoneEvent -> handleTombstoneEvent(action) + is RoomDetailAction.ResendMessage -> handleResendEvent(action) + is RoomDetailAction.RemoveFailedEcho -> handleRemove(action) + is RoomDetailAction.ResendAll -> handleResendAll() + is RoomDetailAction.MarkAllAsRead -> handleMarkAllAsRead() + is RoomDetailAction.ReportContent -> handleReportContent(action) + is RoomDetailAction.IgnoreUser -> handleIgnoreUser(action) is RoomDetailAction.EnterTrackingUnreadMessagesState -> startTrackingUnreadMessages() - is RoomDetailAction.ExitTrackingUnreadMessagesState -> stopTrackingUnreadMessages() - is RoomDetailAction.ReplyToOptions -> handleReplyToOptions(action) - is RoomDetailAction.AcceptVerificationRequest -> handleAcceptVerification(action) - is RoomDetailAction.DeclineVerificationRequest -> handleDeclineVerification(action) - is RoomDetailAction.RequestVerification -> handleRequestVerification(action) - is RoomDetailAction.ResumeVerification -> handleResumeRequestVerification(action) - is RoomDetailAction.ReRequestKeys -> handleReRequestKeys(action) - is RoomDetailAction.TapOnFailedToDecrypt -> handleTapOnFailedToDecrypt(action) - is RoomDetailAction.SelectStickerAttachment -> handleSelectStickerAttachment() - is RoomDetailAction.OpenIntegrationManager -> handleOpenIntegrationManager() - is RoomDetailAction.StartCallWithPhoneNumber -> handleStartCallWithPhoneNumber(action) - is RoomDetailAction.StartCall -> handleStartCall(action) - is RoomDetailAction.AcceptCall -> handleAcceptCall(action) - is RoomDetailAction.EndCall -> handleEndCall() - is RoomDetailAction.ManageIntegrations -> handleManageIntegrations() - is RoomDetailAction.AddJitsiWidget -> handleAddJitsiConference(action) - is RoomDetailAction.RemoveWidget -> handleDeleteWidget(action.widgetId) - is RoomDetailAction.EnsureNativeWidgetAllowed -> handleCheckWidgetAllowed(action) - is RoomDetailAction.CancelSend -> handleCancel(action) - is RoomDetailAction.OpenOrCreateDm -> handleOpenOrCreateDm(action) - is RoomDetailAction.JumpToReadReceipt -> handleJumpToReadReceipt(action) - RoomDetailAction.QuickActionInvitePeople -> handleInvitePeople() - RoomDetailAction.QuickActionSetAvatar -> handleQuickSetAvatar() - is RoomDetailAction.SetAvatarAction -> handleSetNewAvatar(action) - RoomDetailAction.QuickActionSetTopic -> _viewEvents.post(RoomDetailViewEvents.OpenRoomSettings) - is RoomDetailAction.ShowRoomAvatarFullScreen -> { + is RoomDetailAction.ExitTrackingUnreadMessagesState -> stopTrackingUnreadMessages() + is RoomDetailAction.ReplyToOptions -> handleReplyToOptions(action) + is RoomDetailAction.AcceptVerificationRequest -> handleAcceptVerification(action) + is RoomDetailAction.DeclineVerificationRequest -> handleDeclineVerification(action) + is RoomDetailAction.RequestVerification -> handleRequestVerification(action) + is RoomDetailAction.ResumeVerification -> handleResumeRequestVerification(action) + is RoomDetailAction.ReRequestKeys -> handleReRequestKeys(action) + is RoomDetailAction.TapOnFailedToDecrypt -> handleTapOnFailedToDecrypt(action) + is RoomDetailAction.SelectStickerAttachment -> handleSelectStickerAttachment() + is RoomDetailAction.OpenIntegrationManager -> handleOpenIntegrationManager() + is RoomDetailAction.StartCallWithPhoneNumber -> handleStartCallWithPhoneNumber(action) + is RoomDetailAction.StartCall -> handleStartCall(action) + is RoomDetailAction.AcceptCall -> handleAcceptCall(action) + is RoomDetailAction.EndCall -> handleEndCall() + is RoomDetailAction.ManageIntegrations -> handleManageIntegrations() + is RoomDetailAction.AddJitsiWidget -> handleAddJitsiConference(action) + is RoomDetailAction.RemoveWidget -> handleDeleteWidget(action.widgetId) + is RoomDetailAction.EnsureNativeWidgetAllowed -> handleCheckWidgetAllowed(action) + is RoomDetailAction.CancelSend -> handleCancel(action) + is RoomDetailAction.OpenOrCreateDm -> handleOpenOrCreateDm(action) + is RoomDetailAction.JumpToReadReceipt -> handleJumpToReadReceipt(action) + RoomDetailAction.QuickActionInvitePeople -> handleInvitePeople() + RoomDetailAction.QuickActionSetAvatar -> handleQuickSetAvatar() + is RoomDetailAction.SetAvatarAction -> handleSetNewAvatar(action) + RoomDetailAction.QuickActionSetTopic -> _viewEvents.post(RoomDetailViewEvents.OpenRoomSettings) + is RoomDetailAction.ShowRoomAvatarFullScreen -> { _viewEvents.post( RoomDetailViewEvents.ShowRoomAvatarFullScreen(action.matrixItem, action.transitionView) ) } - is RoomDetailAction.DoNotShowPreviewUrlFor -> handleDoNotShowPreviewUrlFor(action) - RoomDetailAction.RemoveAllFailedMessages -> handleRemoveAllFailedMessages() - RoomDetailAction.ResendAll -> handleResendAll() + is RoomDetailAction.DoNotShowPreviewUrlFor -> handleDoNotShowPreviewUrlFor(action) + RoomDetailAction.RemoveAllFailedMessages -> handleRemoveAllFailedMessages() + RoomDetailAction.ResendAll -> handleResendAll() }.exhaustive } @@ -674,13 +675,13 @@ class RoomDetailViewModel @AssistedInject constructor( } when (itemId) { R.id.timeline_setting -> true - R.id.invite -> state.canInvite + R.id.invite -> state.canInvite R.id.open_matrix_apps -> true R.id.voice_call, - R.id.video_call -> callManager.getCallsByRoomId(state.roomId).isEmpty() - R.id.hangup_call -> callManager.getCallsByRoomId(state.roomId).isNotEmpty() - R.id.search -> true - R.id.dev_tools -> vectorPreferences.developerMode() + R.id.video_call -> callManager.getCallsByRoomId(state.roomId).isEmpty() + R.id.hangup_call -> callManager.getCallsByRoomId(state.roomId).isNotEmpty() + R.id.search -> true + R.id.dev_tools -> vectorPreferences.developerMode() else -> false } } @@ -825,6 +826,67 @@ class RoomDetailViewModel @AssistedInject constructor( ) } } + is ParsedCommand.CreateSpace -> { + viewModelScope.launch(Dispatchers.IO) { + try { + val params = CreateSpaceParams().apply { + name = slashCommandResult.name + invitedUserIds.addAll(slashCommandResult.invitees) + } + val spaceId = session.spaceService().createSpace(params) + session.spaceService().getSpace(spaceId) + ?.addChildren( + state.roomId, + listOf(session.sessionParams.homeServerHost ?: ""), + null, + true + ) + } catch (failure: Throwable) { + _viewEvents.post(RoomDetailViewEvents.SlashCommandResultError(failure)) + } + } + _viewEvents.post(RoomDetailViewEvents.SlashCommandHandled()) + popDraft() + } + is ParsedCommand.AddToSpace -> { + viewModelScope.launch(Dispatchers.IO) { + try { + session.spaceService().getSpace(slashCommandResult.spaceId) + ?.addChildren( + room.roomId, + listOf(session.sessionParams.homeServerHost ?: ""), + null, + false + ) + } catch (failure: Throwable) { + _viewEvents.post(RoomDetailViewEvents.SlashCommandResultError(failure)) + } + } + _viewEvents.post(RoomDetailViewEvents.SlashCommandHandled()) + popDraft() + } + is ParsedCommand.JoinSpace -> { + viewModelScope.launch(Dispatchers.IO) { + try { + session.spaceService().joinSpace(slashCommandResult.spaceIdOrAlias) + } catch (failure: Throwable) { + _viewEvents.post(RoomDetailViewEvents.SlashCommandResultError(failure)) + } + } + _viewEvents.post(RoomDetailViewEvents.SlashCommandHandled()) + popDraft() + } + is ParsedCommand.LeaveRoom -> { + viewModelScope.launch(Dispatchers.IO) { + try { + session.getRoom(slashCommandResult.roomId)?.leave(null) + } catch (failure: Throwable) { + _viewEvents.post(RoomDetailViewEvents.SlashCommandResultError(failure)) + } + } + _viewEvents.post(RoomDetailViewEvents.SlashCommandHandled()) + popDraft() + } }.exhaustive } is SendMode.EDIT -> { diff --git a/vector/src/main/java/im/vector/app/features/home/room/detail/timeline/action/MessageActionsViewModel.kt b/vector/src/main/java/im/vector/app/features/home/room/detail/timeline/action/MessageActionsViewModel.kt index b5d3102f46..a3f647871b 100644 --- a/vector/src/main/java/im/vector/app/features/home/room/detail/timeline/action/MessageActionsViewModel.kt +++ b/vector/src/main/java/im/vector/app/features/home/room/detail/timeline/action/MessageActionsViewModel.kt @@ -128,7 +128,8 @@ class MessageActionsViewModel @AssistedInject constructor(@Assisted setState { copy(actionPermissions = permissions) } - }.disposeOnClear() + } + .disposeOnClear() } private fun observeEvent() { diff --git a/vector/src/main/java/im/vector/app/features/home/room/detail/timeline/factory/TimelineItemFactory.kt b/vector/src/main/java/im/vector/app/features/home/room/detail/timeline/factory/TimelineItemFactory.kt index 47bc60eb75..c21fe935bb 100644 --- a/vector/src/main/java/im/vector/app/features/home/room/detail/timeline/factory/TimelineItemFactory.kt +++ b/vector/src/main/java/im/vector/app/features/home/room/detail/timeline/factory/TimelineItemFactory.kt @@ -72,6 +72,8 @@ class TimelineItemFactory @Inject constructor(private val messageItemFactory: Me EventType.CALL_SELECT_ANSWER, EventType.CALL_NEGOTIATE, EventType.REACTION, + EventType.STATE_SPACE_CHILD, + EventType.STATE_SPACE_PARENT, EventType.STATE_ROOM_POWER_LEVELS -> noticeItemFactory.create(params) EventType.STATE_ROOM_WIDGET_LEGACY, EventType.STATE_ROOM_WIDGET -> widgetItemFactory.create(params) diff --git a/vector/src/main/java/im/vector/app/features/home/room/detail/timeline/format/NoticeEventFormatter.kt b/vector/src/main/java/im/vector/app/features/home/room/detail/timeline/format/NoticeEventFormatter.kt index 878cec0a07..b1b96df9ea 100644 --- a/vector/src/main/java/im/vector/app/features/home/room/detail/timeline/format/NoticeEventFormatter.kt +++ b/vector/src/main/java/im/vector/app/features/home/room/detail/timeline/format/NoticeEventFormatter.kt @@ -107,6 +107,8 @@ class NoticeEventFormatter @Inject constructor( EventType.KEY_VERIFICATION_DONE, EventType.KEY_VERIFICATION_KEY, EventType.KEY_VERIFICATION_READY, + EventType.STATE_SPACE_CHILD, + EventType.STATE_SPACE_PARENT, EventType.REDACTION -> formatDebug(timelineEvent.root) else -> { Timber.v("Type $type not handled by this formatter") @@ -119,8 +121,8 @@ class NoticeEventFormatter @Inject constructor( val powerLevelsContent: PowerLevelsContent = event.getClearContent().toModel() ?: return null val previousPowerLevelsContent: PowerLevelsContent = event.resolvedPrevContent().toModel() ?: return null val userIds = HashSet() - userIds.addAll(powerLevelsContent.users.keys) - userIds.addAll(previousPowerLevelsContent.users.keys) + userIds.addAll(powerLevelsContent.users.orEmpty().keys) + userIds.addAll(previousPowerLevelsContent.users.orEmpty().keys) val diffs = ArrayList() userIds.forEach { userId -> val from = PowerLevelsHelper(previousPowerLevelsContent).getUserRole(userId) diff --git a/vector/src/main/java/im/vector/app/features/home/room/list/CollapsableControllerExtension.kt b/vector/src/main/java/im/vector/app/features/home/room/list/CollapsableControllerExtension.kt new file mode 100644 index 0000000000..bd622faae1 --- /dev/null +++ b/vector/src/main/java/im/vector/app/features/home/room/list/CollapsableControllerExtension.kt @@ -0,0 +1,32 @@ +/* + * Copyright (c) 2021 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 im.vector.app.features.home.room.list + +import com.airbnb.epoxy.EpoxyController +import timber.log.Timber + +fun EpoxyController.setCollapsed(collapsed: Boolean) { + if (this is CollapsableControllerExtension) { + this.collapsed = collapsed + } else { + Timber.w("Try to collapse a controller that do not support collapse state") + } +} + +interface CollapsableControllerExtension { + var collapsed: Boolean +} diff --git a/vector/src/main/java/im/vector/app/features/home/room/list/CollapsableTypedEpoxyController.kt b/vector/src/main/java/im/vector/app/features/home/room/list/CollapsableTypedEpoxyController.kt new file mode 100644 index 0000000000..19d718a3c7 --- /dev/null +++ b/vector/src/main/java/im/vector/app/features/home/room/list/CollapsableTypedEpoxyController.kt @@ -0,0 +1,83 @@ +/* + * Copyright (c) 2021 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 im.vector.app.features.home.room.list + +import com.airbnb.epoxy.EpoxyController + +abstract class CollapsableTypedEpoxyController + : EpoxyController(), CollapsableControllerExtension { + + private var currentData: T? = null + private var allowModelBuildRequests = false + + override var collapsed: Boolean = false + set(value) { + if (field != value) { + field = value + allowModelBuildRequests = true + requestModelBuild() + allowModelBuildRequests = false + } + } + + fun setData(data: T?) { + currentData = data + allowModelBuildRequests = true + requestModelBuild() + allowModelBuildRequests = false + } + + override fun requestModelBuild() { + check(allowModelBuildRequests) { + ("You cannot call `requestModelBuild` directly. Call `setData` instead to trigger a " + + "model refresh with new data.") + } + super.requestModelBuild() + } + + override fun moveModel(fromPosition: Int, toPosition: Int) { + allowModelBuildRequests = true + super.moveModel(fromPosition, toPosition) + allowModelBuildRequests = false + } + + override fun requestDelayedModelBuild(delayMs: Int) { + check(allowModelBuildRequests) { + ("You cannot call `requestModelBuild` directly. Call `setData` instead to trigger a " + + "model refresh with new data.") + } + super.requestDelayedModelBuild(delayMs) + } + + fun getCurrentData(): T? { + return currentData + } + + override fun buildModels() { + check(isBuildingModels()) { + ("You cannot call `buildModels` directly. Call `setData` instead to trigger a model " + + "refresh with new data.") + } + if (collapsed) { + buildModels(null) + } else { + buildModels(currentData) + } + } + + protected abstract fun buildModels(data: T?) +} diff --git a/vector/src/main/java/im/vector/app/features/home/room/list/GroupRoomListSectionBuilder.kt b/vector/src/main/java/im/vector/app/features/home/room/list/GroupRoomListSectionBuilder.kt new file mode 100644 index 0000000000..22b0eb091c --- /dev/null +++ b/vector/src/main/java/im/vector/app/features/home/room/list/GroupRoomListSectionBuilder.kt @@ -0,0 +1,262 @@ +/* + * Copyright (c) 2021 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 im.vector.app.features.home.room.list + +import androidx.annotation.StringRes +import im.vector.app.AppStateHandler +import im.vector.app.R +import im.vector.app.RoomGroupingMethod +import im.vector.app.core.resources.StringProvider +import im.vector.app.features.home.RoomListDisplayMode +import io.reactivex.disposables.Disposable +import io.reactivex.schedulers.Schedulers +import kotlinx.coroutines.CoroutineScope +import org.matrix.android.sdk.api.query.RoomCategoryFilter +import org.matrix.android.sdk.api.query.RoomTagQueryFilter +import org.matrix.android.sdk.api.session.Session +import org.matrix.android.sdk.api.session.room.RoomSummaryQueryParams +import org.matrix.android.sdk.api.session.room.UpdatableLivePageResult +import org.matrix.android.sdk.api.session.room.model.Membership +import org.matrix.android.sdk.rx.asObservable + +class GroupRoomListSectionBuilder( + val session: Session, + val stringProvider: StringProvider, + val viewModelScope: CoroutineScope, + val appStateHandler: AppStateHandler, + val onDisposable: (Disposable) -> Unit, + val onUdpatable: (UpdatableLivePageResult) -> Unit +) : RoomListSectionBuilder { + + override fun buildSections(mode: RoomListDisplayMode): List { + val activeGroupAwareQueries = mutableListOf() + val sections = mutableListOf() + val actualGroupId = appStateHandler.safeActiveGroupId() + + when (mode) { + RoomListDisplayMode.PEOPLE -> { + // 3 sections Invites / Fav / Dms + buildPeopleSections(sections, activeGroupAwareQueries, actualGroupId) + } + RoomListDisplayMode.ROOMS -> { + // 5 sections invites / Fav / Rooms / Low Priority / Server notice + buildRoomsSections(sections, activeGroupAwareQueries, actualGroupId) + } + RoomListDisplayMode.FILTERED -> { + // Used when searching for rooms + withQueryParams( + { + it.memberships = Membership.activeMemberships() + }, + { qpm -> + val name = stringProvider.getString(R.string.bottom_action_rooms) + session.getFilteredPagedRoomSummariesLive(qpm) + .let { updatableFilterLivePageResult -> + onUdpatable(updatableFilterLivePageResult) + sections.add(RoomsSection(name, updatableFilterLivePageResult.livePagedList)) + } + } + ) + } + RoomListDisplayMode.NOTIFICATIONS -> { + addSection( + sections, + activeGroupAwareQueries, + R.string.invitations_header, + true + ) { + it.memberships = listOf(Membership.INVITE) + it.roomCategoryFilter = RoomCategoryFilter.ALL + it.activeGroupId = actualGroupId + } + + addSection( + sections, + activeGroupAwareQueries, + R.string.bottom_action_rooms, + false + ) { + it.memberships = listOf(Membership.JOIN) + it.roomCategoryFilter = RoomCategoryFilter.ONLY_WITH_NOTIFICATIONS + it.activeGroupId = actualGroupId + } + } + } + + appStateHandler.selectedRoomGroupingObservable + .distinctUntilChanged() + .subscribe { groupingMethod -> + val selectedGroupId = (groupingMethod.orNull() as? RoomGroupingMethod.ByLegacyGroup)?.groupSummary?.groupId + activeGroupAwareQueries.onEach { updater -> + updater.updateQuery { query -> + query.copy(activeGroupId = selectedGroupId) + } + } + }.also { + onDisposable.invoke(it) + } + return sections + } + + private fun buildRoomsSections(sections: MutableList, + activeSpaceAwareQueries: MutableList, + actualGroupId: String?) { + addSection( + sections, + activeSpaceAwareQueries, + R.string.invitations_header, + true + ) { + it.memberships = listOf(Membership.INVITE) + it.roomCategoryFilter = RoomCategoryFilter.ONLY_ROOMS + it.activeGroupId = actualGroupId + } + + addSection( + sections, + activeSpaceAwareQueries, + R.string.bottom_action_favourites, + false + ) { + it.memberships = listOf(Membership.JOIN) + it.roomCategoryFilter = RoomCategoryFilter.ONLY_ROOMS + it.roomTagQueryFilter = RoomTagQueryFilter(true, null, null) + it.activeGroupId = actualGroupId + } + + addSection( + sections, + activeSpaceAwareQueries, + R.string.bottom_action_rooms, + false + ) { + it.memberships = listOf(Membership.JOIN) + it.roomCategoryFilter = RoomCategoryFilter.ONLY_ROOMS + it.roomTagQueryFilter = RoomTagQueryFilter(false, false, false) + it.activeGroupId = actualGroupId + } + + addSection( + sections, + activeSpaceAwareQueries, + R.string.low_priority_header, + false + ) { + it.memberships = listOf(Membership.JOIN) + it.roomCategoryFilter = RoomCategoryFilter.ONLY_ROOMS + it.roomTagQueryFilter = RoomTagQueryFilter(null, true, null) + it.activeGroupId = actualGroupId + } + + addSection( + sections, + activeSpaceAwareQueries, + R.string.system_alerts_header, + false + ) { + it.memberships = listOf(Membership.JOIN) + it.roomCategoryFilter = RoomCategoryFilter.ONLY_ROOMS + it.roomTagQueryFilter = RoomTagQueryFilter(null, null, true) + it.activeGroupId = actualGroupId + } + } + + private fun buildPeopleSections( + sections: MutableList, + activeSpaceAwareQueries: MutableList, + actualGroupId: String? + ) { + addSection(sections, + activeSpaceAwareQueries, + R.string.invitations_header, + true + ) { + it.memberships = listOf(Membership.INVITE) + it.roomCategoryFilter = RoomCategoryFilter.ONLY_DM + it.activeGroupId = actualGroupId + } + + addSection( + sections, + activeSpaceAwareQueries, + R.string.bottom_action_favourites, + false + ) { + it.memberships = listOf(Membership.JOIN) + it.roomCategoryFilter = RoomCategoryFilter.ONLY_DM + it.roomTagQueryFilter = RoomTagQueryFilter(true, null, null) + it.activeGroupId = actualGroupId + } + + addSection( + sections, + activeSpaceAwareQueries, + R.string.bottom_action_people_x, + false + ) { + it.memberships = listOf(Membership.JOIN) + it.roomCategoryFilter = RoomCategoryFilter.ONLY_DM + it.roomTagQueryFilter = RoomTagQueryFilter(false, null, null) + it.activeGroupId = actualGroupId + } + } + + private fun addSection(sections: MutableList, + activeSpaceUpdaters: MutableList, + @StringRes nameRes: Int, + notifyOfLocalEcho: Boolean = false, + query: (RoomSummaryQueryParams.Builder) -> Unit) { + withQueryParams( + { query.invoke(it) }, + { roomQueryParams -> + + val name = stringProvider.getString(nameRes) + session.getFilteredPagedRoomSummariesLive(roomQueryParams) + .also { + activeSpaceUpdaters.add(it) + }.livePagedList + .let { livePagedList -> + // use it also as a source to update count + livePagedList.asObservable() + .observeOn(Schedulers.computation()) + .subscribe { + sections.find { it.sectionName == name } + ?.notificationCount + ?.postValue(session.getNotificationCountForRooms(roomQueryParams)) + }.also { + onDisposable.invoke(it) + } + sections.add( + RoomsSection( + sectionName = name, + livePages = livePagedList, + notifyOfLocalEcho = notifyOfLocalEcho + ) + ) + } + } + + ) + } + + private fun withQueryParams(builder: (RoomSummaryQueryParams.Builder) -> Unit, block: (RoomSummaryQueryParams) -> Unit) { + RoomSummaryQueryParams.Builder() + .apply { builder.invoke(this) } + .build() + .let { block(it) } + } +} diff --git a/vector/src/main/java/im/vector/app/features/home/room/list/RoomListAction.kt b/vector/src/main/java/im/vector/app/features/home/room/list/RoomListAction.kt index 883efb2e60..37f7d148aa 100644 --- a/vector/src/main/java/im/vector/app/features/home/room/list/RoomListAction.kt +++ b/vector/src/main/java/im/vector/app/features/home/room/list/RoomListAction.kt @@ -29,4 +29,5 @@ sealed class RoomListAction : VectorViewModelAction { data class ChangeRoomNotificationState(val roomId: String, val notificationState: RoomNotificationState) : RoomListAction() data class ToggleTag(val roomId: String, val tag: String) : RoomListAction() data class LeaveRoom(val roomId: String) : RoomListAction() + data class JoinSuggestedRoom(val roomId: String, val viaServers: List?) : RoomListAction() } diff --git a/vector/src/main/java/im/vector/app/features/home/room/list/RoomListFragment.kt b/vector/src/main/java/im/vector/app/features/home/room/list/RoomListFragment.kt index aaa5bbcde5..76d7752ea7 100644 --- a/vector/src/main/java/im/vector/app/features/home/room/list/RoomListFragment.kt +++ b/vector/src/main/java/im/vector/app/features/home/room/list/RoomListFragment.kt @@ -28,6 +28,7 @@ import androidx.core.view.isVisible import androidx.recyclerview.widget.ConcatAdapter import androidx.recyclerview.widget.LinearLayoutManager import androidx.recyclerview.widget.RecyclerView +import com.airbnb.epoxy.EpoxyController import com.airbnb.epoxy.OnModelBuildFinishedListener import com.airbnb.mvrx.args import com.airbnb.mvrx.fragmentViewModel @@ -52,6 +53,7 @@ import im.vector.app.features.notifications.NotificationDrawerManager import kotlinx.parcelize.Parcelize import org.matrix.android.sdk.api.extensions.orTrue import org.matrix.android.sdk.api.session.room.model.RoomSummary +import org.matrix.android.sdk.api.session.room.model.SpaceChildInfo import org.matrix.android.sdk.api.session.room.model.tag.RoomTag import org.matrix.android.sdk.api.session.room.notification.RoomNotificationState import javax.inject.Inject @@ -90,12 +92,12 @@ class RoomListFragment @Inject constructor( data class SectionAdapterInfo( var section: SectionKey, - val headerHeaderAdapter: SectionHeaderAdapter, - val contentAdapter: RoomSummaryPagedController + val sectionHeaderAdapter: SectionHeaderAdapter, + val contentEpoxyController: EpoxyController ) private val adapterInfosList = mutableListOf() - private var concatAdapter : ConcatAdapter? = null + private var concatAdapter: ConcatAdapter? = null override fun onViewCreated(view: View, savedInstanceState: Bundle?) { super.onViewCreated(view, savedInstanceState) @@ -106,10 +108,10 @@ class RoomListFragment @Inject constructor( sharedActionViewModel = activityViewModelProvider.get(RoomListQuickActionsSharedActionViewModel::class.java) roomListViewModel.observeViewEvents { when (it) { - is RoomListViewEvents.Loading -> showLoading(it.message) - is RoomListViewEvents.Failure -> showFailure(it.throwable) + is RoomListViewEvents.Loading -> showLoading(it.message) + is RoomListViewEvents.Failure -> showFailure(it.throwable) is RoomListViewEvents.SelectRoom -> handleSelectRoom(it) - is RoomListViewEvents.Done -> Unit + is RoomListViewEvents.Done -> Unit }.exhaustive } @@ -124,33 +126,27 @@ class RoomListFragment @Inject constructor( // it's for invites local echo adapterInfosList.filter { it.section.notifyOfLocalEcho } .onEach { - it.contentAdapter.roomChangeMembershipStates = ms + (it.contentEpoxyController as? RoomSummaryPagedController)?.roomChangeMembershipStates = ms } } } private fun refreshCollapseStates() { - var contentInsertIndex = 1 roomListViewModel.sections.forEachIndexed { index, roomsSection -> val actualBlock = adapterInfosList[index] val isRoomSectionExpanded = roomsSection.isExpanded.value.orTrue() if (actualBlock.section.isExpanded && !isRoomSectionExpanded) { - // we have to remove the content adapter - concatAdapter?.removeAdapter(actualBlock.contentAdapter.adapter) + // mark controller as collapsed + actualBlock.contentEpoxyController.setCollapsed(true) } else if (!actualBlock.section.isExpanded && isRoomSectionExpanded) { - // we must add it back! - concatAdapter?.addAdapter(contentInsertIndex, actualBlock.contentAdapter.adapter) - } - contentInsertIndex = if (isRoomSectionExpanded) { - contentInsertIndex + 2 - } else { - contentInsertIndex + 1 + // we must expand! + actualBlock.contentEpoxyController.setCollapsed(false) } actualBlock.section = actualBlock.section.copy( isExpanded = isRoomSectionExpanded ) - actualBlock.headerHeaderAdapter.updateSection( - actualBlock.headerHeaderAdapter.roomsSectionData.copy(isExpanded = isRoomSectionExpanded) + actualBlock.sectionHeaderAdapter.updateSection( + actualBlock.sectionHeaderAdapter.roomsSectionData.copy(isExpanded = isRoomSectionExpanded) ) } } @@ -160,7 +156,7 @@ class RoomListFragment @Inject constructor( } override fun onDestroyView() { - adapterInfosList.onEach { it.contentAdapter.removeModelBuildListener(modelBuildListener) } + adapterInfosList.onEach { it.contentEpoxyController.removeModelBuildListener(modelBuildListener) } adapterInfosList.clear() modelBuildListener = null views.roomListView.cleanup() @@ -179,8 +175,8 @@ class RoomListFragment @Inject constructor( private fun setupCreateRoomButton() { when (roomListParams.displayMode) { RoomListDisplayMode.NOTIFICATIONS -> views.createChatFabMenu.isVisible = true - RoomListDisplayMode.PEOPLE -> views.createChatRoomButton.isVisible = true - RoomListDisplayMode.ROOMS -> views.createGroupRoomButton.isVisible = true + RoomListDisplayMode.PEOPLE -> views.createChatRoomButton.isVisible = true + RoomListDisplayMode.ROOMS -> views.createGroupRoomButton.isVisible = true else -> Unit // No button in this mode } @@ -248,23 +244,70 @@ class RoomListFragment @Inject constructor( it.updateSection(SectionHeaderAdapter.RoomsSectionData(section.sectionName)) } - val contentAdapter = pagedControllerFactory.createRoomSummaryPagedController() - .also { controller -> - section.livePages.observe(viewLifecycleOwner) { pl -> - controller.submitList(pl) - sectionAdapter.updateSection(sectionAdapter.roomsSectionData.copy(isHidden = pl.isEmpty())) - checkEmptyState() + val contentAdapter = + when { + section.livePages != null -> { + pagedControllerFactory.createRoomSummaryPagedController() + .also { controller -> + section.livePages.observe(viewLifecycleOwner) { pl -> + controller.submitList(pl) + sectionAdapter.updateSection(sectionAdapter.roomsSectionData.copy( + isHidden = pl.isEmpty(), + isLoading = false + )) + checkEmptyState() + } + section.notificationCount.observe(viewLifecycleOwner) { counts -> + sectionAdapter.updateSection(sectionAdapter.roomsSectionData.copy( + notificationCount = counts.totalCount, + isHighlighted = counts.isHighlight + )) + } + section.isExpanded.observe(viewLifecycleOwner) { _ -> + refreshCollapseStates() + } + controller.listener = this + } } - section.notificationCount.observe(viewLifecycleOwner) { counts -> - sectionAdapter.updateSection(sectionAdapter.roomsSectionData.copy( - notificationCount = counts.totalCount, - isHighlighted = counts.isHighlight - )) + section.liveSuggested != null -> { + pagedControllerFactory.createSuggestedRoomListController() + .also { controller -> + section.liveSuggested.observe(viewLifecycleOwner) { info -> + controller.setData(info) + sectionAdapter.updateSection(sectionAdapter.roomsSectionData.copy( + isHidden = info.rooms.isEmpty(), + isLoading = false + )) + checkEmptyState() + } + section.isExpanded.observe(viewLifecycleOwner) { _ -> + refreshCollapseStates() + } + controller.listener = this + } } - section.isExpanded.observe(viewLifecycleOwner) { _ -> - refreshCollapseStates() + else -> { + pagedControllerFactory.createRoomSummaryListController() + .also { controller -> + section.liveList?.observe(viewLifecycleOwner) { list -> + controller.setData(list) + sectionAdapter.updateSection(sectionAdapter.roomsSectionData.copy( + isHidden = list.isEmpty(), + isLoading = false)) + checkEmptyState() + } + section.notificationCount.observe(viewLifecycleOwner) { counts -> + sectionAdapter.updateSection(sectionAdapter.roomsSectionData.copy( + notificationCount = counts.totalCount, + isHighlighted = counts.isHighlight + )) + } + section.isExpanded.observe(viewLifecycleOwner) { _ -> + refreshCollapseStates() + } + controller.listener = this + } } - controller.listener = this } adapterInfosList.add( SectionAdapterInfo( @@ -293,8 +336,8 @@ class RoomListFragment @Inject constructor( if (isAdded) { when (roomListParams.displayMode) { RoomListDisplayMode.NOTIFICATIONS -> views.createChatFabMenu.show() - RoomListDisplayMode.PEOPLE -> views.createChatRoomButton.show() - RoomListDisplayMode.ROOMS -> views.createGroupRoomButton.show() + RoomListDisplayMode.PEOPLE -> views.createChatRoomButton.show() + RoomListDisplayMode.ROOMS -> views.createGroupRoomButton.show() else -> Unit } } @@ -358,8 +401,9 @@ class RoomListFragment @Inject constructor( } private fun checkEmptyState() { - val hasNoRoom = adapterInfosList.all { it.headerHeaderAdapter.roomsSectionData.isHidden } - if (hasNoRoom) { + val shouldShowEmpty = adapterInfosList.all { it.sectionHeaderAdapter.roomsSectionData.isHidden } + && !adapterInfosList.any { it.sectionHeaderAdapter.roomsSectionData.isLoading } + if (shouldShowEmpty) { val emptyState = when (roomListParams.displayMode) { RoomListDisplayMode.NOTIFICATIONS -> { StateView.State.Empty( @@ -367,14 +411,14 @@ class RoomListFragment @Inject constructor( image = ContextCompat.getDrawable(requireContext(), R.drawable.ic_noun_party_popper), message = getString(R.string.room_list_catchup_empty_body)) } - RoomListDisplayMode.PEOPLE -> + RoomListDisplayMode.PEOPLE -> StateView.State.Empty( title = getString(R.string.room_list_people_empty_title), image = ContextCompat.getDrawable(requireContext(), R.drawable.empty_state_dm), isBigImage = true, message = getString(R.string.room_list_people_empty_body) ) - RoomListDisplayMode.ROOMS -> + RoomListDisplayMode.ROOMS -> StateView.State.Empty( title = getString(R.string.room_list_rooms_empty_title), image = ContextCompat.getDrawable(requireContext(), R.drawable.empty_state_room), @@ -387,7 +431,12 @@ class RoomListFragment @Inject constructor( } views.stateView.state = emptyState } else { - views.stateView.state = StateView.State.Content + // is there something to show already? + if (adapterInfosList.any { !it.sectionHeaderAdapter.roomsSectionData.isHidden }) { + views.stateView.state = StateView.State.Content + } else { + views.stateView.state = StateView.State.Loading + } } } @@ -421,6 +470,10 @@ class RoomListFragment @Inject constructor( roomListViewModel.handle(RoomListAction.AcceptInvitation(room)) } + override fun onJoinSuggestedRoom(room: SpaceChildInfo) { + roomListViewModel.handle(RoomListAction.JoinSuggestedRoom(room.childRoomId, room.viaServers)) + } + override fun onRejectRoomInvitation(room: RoomSummary) { notificationDrawerManager.clearMemberShipNotificationForRoom(room.roomId) roomListViewModel.handle(RoomListAction.RejectInvitation(room)) diff --git a/vector/src/main/java/im/vector/app/features/home/room/list/RoomListListener.kt b/vector/src/main/java/im/vector/app/features/home/room/list/RoomListListener.kt index e9833d1560..0ba265f841 100644 --- a/vector/src/main/java/im/vector/app/features/home/room/list/RoomListListener.kt +++ b/vector/src/main/java/im/vector/app/features/home/room/list/RoomListListener.kt @@ -18,10 +18,12 @@ package im.vector.app.features.home.room.list import im.vector.app.features.home.room.filtered.FilteredRoomFooterItem import org.matrix.android.sdk.api.session.room.model.RoomSummary +import org.matrix.android.sdk.api.session.room.model.SpaceChildInfo interface RoomListListener : FilteredRoomFooterItem.FilteredRoomFooterItemListener { fun onRoomClicked(room: RoomSummary) fun onRoomLongClicked(room: RoomSummary): Boolean fun onRejectRoomInvitation(room: RoomSummary) fun onAcceptRoomInvitation(room: RoomSummary) + fun onJoinSuggestedRoom(room: SpaceChildInfo) } diff --git a/vector/src/main/java/im/vector/app/features/home/room/list/RoomListSectionBuilder.kt b/vector/src/main/java/im/vector/app/features/home/room/list/RoomListSectionBuilder.kt new file mode 100644 index 0000000000..5267158000 --- /dev/null +++ b/vector/src/main/java/im/vector/app/features/home/room/list/RoomListSectionBuilder.kt @@ -0,0 +1,23 @@ +/* + * Copyright (c) 2021 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 im.vector.app.features.home.room.list + +import im.vector.app.features.home.RoomListDisplayMode + +interface RoomListSectionBuilder { + fun buildSections(mode: RoomListDisplayMode) : List +} diff --git a/vector/src/main/java/im/vector/app/features/home/room/list/RoomListViewModel.kt b/vector/src/main/java/im/vector/app/features/home/room/list/RoomListViewModel.kt index 423a950591..bc24705e13 100644 --- a/vector/src/main/java/im/vector/app/features/home/room/list/RoomListViewModel.kt +++ b/vector/src/main/java/im/vector/app/features/home/room/list/RoomListViewModel.kt @@ -16,32 +16,30 @@ package im.vector.app.features.home.room.list -import androidx.annotation.StringRes +import androidx.lifecycle.MutableLiveData import androidx.lifecycle.viewModelScope +import com.airbnb.mvrx.Async +import com.airbnb.mvrx.Fail import com.airbnb.mvrx.FragmentViewModelContext +import com.airbnb.mvrx.Loading import com.airbnb.mvrx.MvRxViewModelFactory +import com.airbnb.mvrx.Success import com.airbnb.mvrx.ViewModelContext -import im.vector.app.R +import im.vector.app.AppStateHandler +import im.vector.app.RoomGroupingMethod import im.vector.app.core.extensions.exhaustive import im.vector.app.core.platform.VectorViewModel import im.vector.app.core.resources.StringProvider -import im.vector.app.features.home.RoomListDisplayMode -import io.reactivex.schedulers.Schedulers +import im.vector.app.features.settings.VectorPreferences import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.launch import org.matrix.android.sdk.api.extensions.orFalse import org.matrix.android.sdk.api.query.QueryStringValue -import org.matrix.android.sdk.api.query.RoomCategoryFilter -import org.matrix.android.sdk.api.query.RoomTagQueryFilter import org.matrix.android.sdk.api.session.Session -import org.matrix.android.sdk.api.session.room.RoomSummaryQueryParams -import org.matrix.android.sdk.api.session.room.UpdatableFilterLivePageResult +import org.matrix.android.sdk.api.session.room.UpdatableLivePageResult import org.matrix.android.sdk.api.session.room.members.ChangeMembershipState -import org.matrix.android.sdk.api.session.room.model.Membership import org.matrix.android.sdk.api.session.room.model.tag.RoomTag -import org.matrix.android.sdk.api.session.room.roomSummaryQueryParams import org.matrix.android.sdk.api.session.room.state.isPublic -import org.matrix.android.sdk.rx.asObservable import org.matrix.android.sdk.rx.rx import timber.log.Timber import javax.inject.Inject @@ -49,17 +47,57 @@ import javax.inject.Inject class RoomListViewModel @Inject constructor( initialState: RoomListViewState, private val session: Session, - private val stringProvider: StringProvider + private val stringProvider: StringProvider, + private val appStateHandler: AppStateHandler, + private val vectorPreferences: VectorPreferences ) : VectorViewModel(initialState) { interface Factory { fun create(initialState: RoomListViewState): RoomListViewModel } - private var updatableQuery: UpdatableFilterLivePageResult? = null + private var updatableQuery: UpdatableLivePageResult? = null + + private val suggestedRoomJoiningState: MutableLiveData>> = MutableLiveData(emptyMap()) + + interface ActiveSpaceQueryUpdater { + fun updateForSpaceId(roomId: String?) + } + + enum class SpaceFilterStrategy { + /** + * Filter the rooms if they are part of the current space (children and grand children). + * If current space is null, will return orphan rooms only + */ + NORMAL, + /** + * Special case when we don't want to discriminate rooms when current space is null. + * In this case return all. + */ + NOT_IF_ALL, + /** Do not filter based on space*/ + NONE + } init { observeMembershipChanges() + + appStateHandler.selectedRoomGroupingObservable + .distinctUntilChanged() + .execute { + copy( + currentRoomGrouping = it.invoke()?.orNull()?.let { Success(it) } ?: Loading() + ) + } + + session.rx().liveUser(session.myUserId) + .map { it.getOrNull()?.getBestName() } + .distinctUntilChanged() + .execute { + copy( + currentUserName = it.invoke() ?: session.myUserId + ) + } } private fun observeMembershipChanges() { @@ -81,79 +119,34 @@ class RoomListViewModel @Inject constructor( } val sections: List by lazy { - val sections = mutableListOf() - if (initialState.displayMode == RoomListDisplayMode.PEOPLE) { - addSection(sections, R.string.invitations_header, true) { - it.memberships = listOf(Membership.INVITE) - it.roomCategoryFilter = RoomCategoryFilter.ONLY_DM - } - - addSection(sections, R.string.bottom_action_favourites) { - it.memberships = listOf(Membership.JOIN) - it.roomCategoryFilter = RoomCategoryFilter.ONLY_DM - it.roomTagQueryFilter = RoomTagQueryFilter(true, null, null) - } - - addSection(sections, R.string.bottom_action_people_x) { - it.memberships = listOf(Membership.JOIN) - it.roomCategoryFilter = RoomCategoryFilter.ONLY_DM - } - } else if (initialState.displayMode == RoomListDisplayMode.ROOMS) { - addSection(sections, R.string.invitations_header, true) { - it.memberships = listOf(Membership.INVITE) - it.roomCategoryFilter = RoomCategoryFilter.ONLY_ROOMS - } - - addSection(sections, R.string.bottom_action_favourites) { - it.memberships = listOf(Membership.JOIN) - it.roomCategoryFilter = RoomCategoryFilter.ONLY_ROOMS - it.roomTagQueryFilter = RoomTagQueryFilter(true, null, null) - } - - addSection(sections, R.string.bottom_action_rooms) { - it.memberships = listOf(Membership.JOIN) - it.roomCategoryFilter = RoomCategoryFilter.ONLY_ROOMS - it.roomTagQueryFilter = RoomTagQueryFilter(false, false, false) - } - - addSection(sections, R.string.low_priority_header) { - it.memberships = listOf(Membership.JOIN) - it.roomCategoryFilter = RoomCategoryFilter.ONLY_ROOMS - it.roomTagQueryFilter = RoomTagQueryFilter(null, true, null) - } - - addSection(sections, R.string.system_alerts_header) { - it.memberships = listOf(Membership.JOIN) - it.roomCategoryFilter = RoomCategoryFilter.ONLY_ROOMS - it.roomTagQueryFilter = RoomTagQueryFilter(null, null, true) - } - } else if (initialState.displayMode == RoomListDisplayMode.FILTERED) { - withQueryParams( + if (appStateHandler.getCurrentRoomGroupingMethod() is RoomGroupingMethod.BySpace) { + SpaceRoomListSectionBuilder( + session, + stringProvider, + appStateHandler, + viewModelScope, + suggestedRoomJoiningState, { - it.memberships = Membership.activeMemberships() + it.disposeOnClear() }, - { qpm -> - val name = stringProvider.getString(R.string.bottom_action_rooms) - session.getFilteredPagedRoomSummariesLive(qpm) - .let { updatableFilterLivePageResult -> - updatableQuery = updatableFilterLivePageResult - sections.add(RoomsSection(name, updatableFilterLivePageResult.livePagedList)) - } + { + updatableQuery = it } - ) - } else if (initialState.displayMode == RoomListDisplayMode.NOTIFICATIONS) { - addSection(sections, R.string.invitations_header, true) { - it.memberships = listOf(Membership.INVITE) - it.roomCategoryFilter = RoomCategoryFilter.ALL - } - - addSection(sections, R.string.bottom_action_rooms, true) { - it.memberships = listOf(Membership.JOIN) - it.roomCategoryFilter = RoomCategoryFilter.ONLY_WITH_NOTIFICATIONS - } + ).buildSections(initialState.displayMode) + } else { + GroupRoomListSectionBuilder( + session, + stringProvider, + viewModelScope, + appStateHandler, + { + it.disposeOnClear() + }, + { + updatableQuery = it + } + ).buildSections(initialState.displayMode) } - - sections } override fun handle(action: RoomListAction) { @@ -166,50 +159,10 @@ class RoomListViewModel @Inject constructor( is RoomListAction.ChangeRoomNotificationState -> handleChangeNotificationMode(action) is RoomListAction.ToggleTag -> handleToggleTag(action) is RoomListAction.ToggleSection -> handleToggleSection(action.section) + is RoomListAction.JoinSuggestedRoom -> handleJoinSuggestedRoom(action) }.exhaustive } - private fun addSection(sections: MutableList, - @StringRes nameRes: Int, - notifyOfLocalEcho: Boolean = false, - query: (RoomSummaryQueryParams.Builder) -> Unit) { - withQueryParams( - { query.invoke(it) }, - { roomQueryParams -> - - val name = stringProvider.getString(nameRes) - session.getPagedRoomSummariesLive(roomQueryParams) - .let { livePagedList -> - - // use it also as a source to update count - livePagedList.asObservable() - .observeOn(Schedulers.computation()) - .subscribe { - sections.find { it.sectionName == name } - ?.notificationCount - ?.postValue(session.getNotificationCountForRooms(roomQueryParams)) - } - .disposeOnClear() - - sections.add( - RoomsSection( - sectionName = name, - livePages = livePagedList, - notifyOfLocalEcho = notifyOfLocalEcho - ) - ) - } - } - ) - } - - private fun withQueryParams(builder: (RoomSummaryQueryParams.Builder) -> Unit, block: (RoomSummaryQueryParams) -> Unit) { - RoomSummaryQueryParams.Builder() - .apply { builder.invoke(this) } - .build() - .let { block(it) } - } - fun isPublicRoom(roomId: String): Boolean { return session.getRoom(roomId)?.isPublic().orFalse() } @@ -236,12 +189,11 @@ class RoomListViewModel @Inject constructor( roomFilter = action.filter ) } - updatableQuery?.updateQuery( - roomSummaryQueryParams { - memberships = Membership.activeMemberships() + updatableQuery?.updateQuery { + it.copy( displayName = QueryStringValue.Contains(action.filter, QueryStringValue.Case.INSENSITIVE) - } - ) + ) + } } private fun handleAcceptInvitation(action: RoomListAction.AcceptInvitation) = withState { state -> @@ -316,6 +268,26 @@ class RoomListViewModel @Inject constructor( } } + private fun handleJoinSuggestedRoom(action: RoomListAction.JoinSuggestedRoom) { + suggestedRoomJoiningState.postValue(suggestedRoomJoiningState.value.orEmpty().toMutableMap().apply { + this[action.roomId] = Loading() + }.toMap()) + + viewModelScope.launch { + try { + session.joinRoom(action.roomId, null, action.viaServers ?: emptyList()) + + suggestedRoomJoiningState.postValue(suggestedRoomJoiningState.value.orEmpty().toMutableMap().apply { + this[action.roomId] = Success(Unit) + }.toMap()) + } catch (failure: Throwable) { + suggestedRoomJoiningState.postValue(suggestedRoomJoiningState.value.orEmpty().toMutableMap().apply { + this[action.roomId] = Fail(failure) + }.toMap()) + } + } + } + private fun handleToggleTag(action: RoomListAction.ToggleTag) { session.getRoom(action.roomId)?.let { room -> viewModelScope.launch(Dispatchers.IO) { @@ -342,7 +314,7 @@ class RoomListViewModel @Inject constructor( private fun String.otherTag(): String? { return when (this) { - RoomTag.ROOM_TAG_FAVOURITE -> RoomTag.ROOM_TAG_LOW_PRIORITY + RoomTag.ROOM_TAG_FAVOURITE -> RoomTag.ROOM_TAG_LOW_PRIORITY RoomTag.ROOM_TAG_LOW_PRIORITY -> RoomTag.ROOM_TAG_FAVOURITE else -> null } diff --git a/vector/src/main/java/im/vector/app/features/home/room/list/RoomListViewModelFactory.kt b/vector/src/main/java/im/vector/app/features/home/room/list/RoomListViewModelFactory.kt index d36bc45ab6..a30c175f41 100644 --- a/vector/src/main/java/im/vector/app/features/home/room/list/RoomListViewModelFactory.kt +++ b/vector/src/main/java/im/vector/app/features/home/room/list/RoomListViewModelFactory.kt @@ -16,20 +16,26 @@ package im.vector.app.features.home.room.list +import im.vector.app.AppStateHandler import im.vector.app.core.resources.StringProvider +import im.vector.app.features.settings.VectorPreferences import org.matrix.android.sdk.api.session.Session import javax.inject.Inject import javax.inject.Provider class RoomListViewModelFactory @Inject constructor(private val session: Provider, - private val stringProvider: StringProvider) + private val appStateHandler: AppStateHandler, + private val stringProvider: StringProvider, + private val vectorPreferences: VectorPreferences) : RoomListViewModel.Factory { override fun create(initialState: RoomListViewState): RoomListViewModel { return RoomListViewModel( initialState, session.get(), - stringProvider + stringProvider, + appStateHandler, + vectorPreferences ) } } diff --git a/vector/src/main/java/im/vector/app/features/home/room/list/RoomListViewState.kt b/vector/src/main/java/im/vector/app/features/home/room/list/RoomListViewState.kt index 104a3710f7..68a8b9e515 100644 --- a/vector/src/main/java/im/vector/app/features/home/room/list/RoomListViewState.kt +++ b/vector/src/main/java/im/vector/app/features/home/room/list/RoomListViewState.kt @@ -16,14 +16,21 @@ package im.vector.app.features.home.room.list +import com.airbnb.mvrx.Async import com.airbnb.mvrx.MvRxState +import com.airbnb.mvrx.Uninitialized +import im.vector.app.RoomGroupingMethod import im.vector.app.features.home.RoomListDisplayMode import org.matrix.android.sdk.api.session.room.members.ChangeMembershipState +import org.matrix.android.sdk.api.session.room.model.SpaceChildInfo data class RoomListViewState( val displayMode: RoomListDisplayMode, val roomFilter: String = "", - val roomMembershipChanges: Map = emptyMap() + val roomMembershipChanges: Map = emptyMap(), + val asyncSuggestedRooms: Async> = Uninitialized, + val currentUserName: String? = null, + val currentRoomGrouping: Async = Uninitialized ) : MvRxState { constructor(args: RoomListParams) : this(displayMode = args.displayMode) diff --git a/vector/src/main/java/im/vector/app/features/home/room/list/RoomSummaryItemFactory.kt b/vector/src/main/java/im/vector/app/features/home/room/list/RoomSummaryItemFactory.kt index fa6c970d8a..283ed0ac85 100644 --- a/vector/src/main/java/im/vector/app/features/home/room/list/RoomSummaryItemFactory.kt +++ b/vector/src/main/java/im/vector/app/features/home/room/list/RoomSummaryItemFactory.kt @@ -16,6 +16,9 @@ package im.vector.app.features.home.room.list +import android.view.View +import com.airbnb.mvrx.Async +import com.airbnb.mvrx.Loading import im.vector.app.R import im.vector.app.core.date.DateFormatKind import im.vector.app.core.date.VectorDateFormatter @@ -28,6 +31,7 @@ import im.vector.app.features.home.room.typing.TypingHelper import org.matrix.android.sdk.api.session.room.members.ChangeMembershipState import org.matrix.android.sdk.api.session.room.model.Membership import org.matrix.android.sdk.api.session.room.model.RoomSummary +import org.matrix.android.sdk.api.session.room.model.SpaceChildInfo import org.matrix.android.sdk.api.util.toMatrixItem import javax.inject.Inject @@ -50,6 +54,20 @@ class RoomSummaryItemFactory @Inject constructor(private val displayableEventFor } } + fun createSuggestion(spaceChildInfo: SpaceChildInfo, + suggestedRoomJoiningStates: Map>, + onJoinClick: View.OnClickListener): VectorEpoxyModel<*> { + return SpaceChildInfoItem_() + .id("sug_${spaceChildInfo.childRoomId}") + .matrixItem(spaceChildInfo.toMatrixItem()) + .avatarRenderer(avatarRenderer) + .topic(spaceChildInfo.topic) + .buttonLabel(stringProvider.getString(R.string.join)) + .loading(suggestedRoomJoiningStates[spaceChildInfo.childRoomId] is Loading) + .memberCount(spaceChildInfo.activeMemberCount ?: 0) + .buttonClickListener(onJoinClick) + } + private fun createInvitationItem(roomSummary: RoomSummary, changeMembershipState: ChangeMembershipState, listener: RoomListListener?): VectorEpoxyModel<*> { diff --git a/vector/src/main/java/im/vector/app/features/home/room/list/RoomSummaryItemPlaceHolder.kt b/vector/src/main/java/im/vector/app/features/home/room/list/RoomSummaryItemPlaceHolder.kt new file mode 100644 index 0000000000..96d8f6418b --- /dev/null +++ b/vector/src/main/java/im/vector/app/features/home/room/list/RoomSummaryItemPlaceHolder.kt @@ -0,0 +1,27 @@ +/* + * Copyright 2021 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 im.vector.app.features.home.room.list + +import com.airbnb.epoxy.EpoxyModelClass +import im.vector.app.R +import im.vector.app.core.epoxy.VectorEpoxyHolder +import im.vector.app.core.epoxy.VectorEpoxyModel + +@EpoxyModelClass(layout = R.layout.item_room_placeholder) +abstract class RoomSummaryItemPlaceHolder : VectorEpoxyModel() { + class Holder : VectorEpoxyHolder() +} diff --git a/vector/src/main/java/im/vector/app/features/home/room/list/RoomSummaryListController.kt b/vector/src/main/java/im/vector/app/features/home/room/list/RoomSummaryListController.kt new file mode 100644 index 0000000000..75aaee45cb --- /dev/null +++ b/vector/src/main/java/im/vector/app/features/home/room/list/RoomSummaryListController.kt @@ -0,0 +1,32 @@ +/* + * Copyright (c) 2021 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 im.vector.app.features.home.room.list + +import org.matrix.android.sdk.api.session.room.model.RoomSummary + +class RoomSummaryListController( + private val roomSummaryItemFactory: RoomSummaryItemFactory +) : CollapsableTypedEpoxyController>() { + + var listener: RoomListListener? = null + + override fun buildModels(data: List?) { + data?.forEach { + add(roomSummaryItemFactory.create(it, emptyMap(), emptySet(), listener)) + } + } +} diff --git a/vector/src/main/java/im/vector/app/features/home/room/list/RoomSummaryPagedController.kt b/vector/src/main/java/im/vector/app/features/home/room/list/RoomSummaryPagedController.kt index 20386d739a..e9cbc69215 100644 --- a/vector/src/main/java/im/vector/app/features/home/room/list/RoomSummaryPagedController.kt +++ b/vector/src/main/java/im/vector/app/features/home/room/list/RoomSummaryPagedController.kt @@ -21,23 +21,13 @@ import com.airbnb.epoxy.paging.PagedListEpoxyController import im.vector.app.core.utils.createUIHandler import org.matrix.android.sdk.api.session.room.members.ChangeMembershipState import org.matrix.android.sdk.api.session.room.model.RoomSummary -import javax.inject.Inject - -class RoomSummaryPagedControllerFactory @Inject constructor( - private val roomSummaryItemFactory: RoomSummaryItemFactory -) { - - fun createRoomSummaryPagedController(): RoomSummaryPagedController { - return RoomSummaryPagedController(roomSummaryItemFactory) - } -} class RoomSummaryPagedController( private val roomSummaryItemFactory: RoomSummaryItemFactory ) : PagedListEpoxyController( // Important it must match the PageList builder notify Looper modelBuildingHandler = createUIHandler() -) { +), CollapsableControllerExtension { var listener: RoomListListener? = null @@ -48,21 +38,25 @@ class RoomSummaryPagedController( requestForcedModelBuild() } + override var collapsed = false + set(value) { + if (field != value) { + field = value + requestForcedModelBuild() + } + } + + override fun addModels(models: List>) { + if (collapsed) { + super.addModels(emptyList()) + } else { + super.addModels(models) + } + } + override fun buildItemModel(currentPosition: Int, item: RoomSummary?): EpoxyModel<*> { // for place holder if enabled - item ?: return roomSummaryItemFactory.createRoomItem( - roomSummary = RoomSummary( - roomId = "null_item_pos_$currentPosition", - name = "", - encryptionEventTs = null, - isEncrypted = false, - typingUsers = emptyList() - ), - selectedRoomIds = emptySet(), - onClick = null, - onLongClick = null - ) - + item ?: return RoomSummaryItemPlaceHolder_().apply { id(currentPosition) } return roomSummaryItemFactory.create(item, roomChangeMembershipStates.orEmpty(), emptySet(), listener) } } diff --git a/vector/src/main/java/im/vector/app/features/home/room/list/RoomSummaryPagedControllerFactory.kt b/vector/src/main/java/im/vector/app/features/home/room/list/RoomSummaryPagedControllerFactory.kt new file mode 100644 index 0000000000..c86d8ab243 --- /dev/null +++ b/vector/src/main/java/im/vector/app/features/home/room/list/RoomSummaryPagedControllerFactory.kt @@ -0,0 +1,36 @@ +/* + * Copyright (c) 2021 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 im.vector.app.features.home.room.list + +import javax.inject.Inject + +class RoomSummaryPagedControllerFactory @Inject constructor( + private val roomSummaryItemFactory: RoomSummaryItemFactory +) { + + fun createRoomSummaryPagedController(): RoomSummaryPagedController { + return RoomSummaryPagedController(roomSummaryItemFactory) + } + + fun createRoomSummaryListController(): RoomSummaryListController { + return RoomSummaryListController(roomSummaryItemFactory) + } + + fun createSuggestedRoomListController(): SuggestedRoomListController { + return SuggestedRoomListController(roomSummaryItemFactory) + } +} diff --git a/vector/src/main/java/im/vector/app/features/home/room/list/RoomsSection.kt b/vector/src/main/java/im/vector/app/features/home/room/list/RoomsSection.kt index 71b7169814..5eaae262a6 100644 --- a/vector/src/main/java/im/vector/app/features/home/room/list/RoomsSection.kt +++ b/vector/src/main/java/im/vector/app/features/home/room/list/RoomsSection.kt @@ -24,7 +24,10 @@ import org.matrix.android.sdk.api.session.room.summary.RoomAggregateNotification data class RoomsSection( val sectionName: String, - val livePages: LiveData>, + // can be a paged list or a regular list + val livePages: LiveData>? = null, + val liveList: LiveData>? = null, + val liveSuggested: LiveData? = null, val isExpanded: MutableLiveData = MutableLiveData(true), val notificationCount: MutableLiveData = MutableLiveData(RoomAggregateNotificationCount(0, 0)), val notifyOfLocalEcho: Boolean = false diff --git a/vector/src/main/java/im/vector/app/features/home/room/list/SectionHeaderAdapter.kt b/vector/src/main/java/im/vector/app/features/home/room/list/SectionHeaderAdapter.kt index f9c5766821..6cddf72c5a 100644 --- a/vector/src/main/java/im/vector/app/features/home/room/list/SectionHeaderAdapter.kt +++ b/vector/src/main/java/im/vector/app/features/home/room/list/SectionHeaderAdapter.kt @@ -35,7 +35,9 @@ class SectionHeaderAdapter constructor( val isExpanded: Boolean = true, val notificationCount: Int = 0, val isHighlighted: Boolean = false, - val isHidden: Boolean = true + val isHidden: Boolean = true, + // This will be false until real data has been submitted once + val isLoading: Boolean = true ) lateinit var roomsSectionData: RoomsSectionData diff --git a/vector/src/main/java/im/vector/app/features/home/room/list/SpaceChildInfoItem.kt b/vector/src/main/java/im/vector/app/features/home/room/list/SpaceChildInfoItem.kt new file mode 100644 index 0000000000..cb9c8b1f2e --- /dev/null +++ b/vector/src/main/java/im/vector/app/features/home/room/list/SpaceChildInfoItem.kt @@ -0,0 +1,126 @@ +/* + * Copyright 2019 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 im.vector.app.features.home.room.list + +import android.view.HapticFeedbackConstants +import android.view.View +import android.view.ViewGroup +import android.widget.Button +import android.widget.ImageView +import android.widget.ProgressBar +import android.widget.TextView +import androidx.core.content.ContextCompat +import androidx.core.view.isInvisible +import androidx.core.view.isVisible +import com.airbnb.epoxy.EpoxyAttribute +import com.airbnb.epoxy.EpoxyModelClass +import im.vector.app.R +import im.vector.app.core.epoxy.VectorEpoxyHolder +import im.vector.app.core.epoxy.VectorEpoxyModel +import im.vector.app.features.home.AvatarRenderer +import im.vector.app.features.themes.ThemeUtils +import me.gujun.android.span.image +import me.gujun.android.span.span +import org.matrix.android.sdk.api.util.MatrixItem + +@EpoxyModelClass(layout = R.layout.item_suggested_room) +abstract class SpaceChildInfoItem : VectorEpoxyModel() { + + @EpoxyAttribute lateinit var avatarRenderer: AvatarRenderer + @EpoxyAttribute lateinit var matrixItem: MatrixItem + + // Used only for diff calculation + @EpoxyAttribute var topic: String? = null + + @EpoxyAttribute var memberCount: Int = 0 + @EpoxyAttribute var loading: Boolean = false + @EpoxyAttribute var space: Boolean = false + + @EpoxyAttribute var buttonLabel: String? = null + + @EpoxyAttribute(EpoxyAttribute.Option.DoNotHash) var itemLongClickListener: View.OnLongClickListener? = null + @EpoxyAttribute(EpoxyAttribute.Option.DoNotHash) var itemClickListener: View.OnClickListener? = null + @EpoxyAttribute(EpoxyAttribute.Option.DoNotHash) var buttonClickListener: View.OnClickListener? = null + + override fun bind(holder: Holder) { + super.bind(holder) + holder.rootView.setOnClickListener(itemClickListener) + holder.rootView.setOnLongClickListener { + it.performHapticFeedback(HapticFeedbackConstants.LONG_PRESS) + itemLongClickListener?.onLongClick(it) ?: false + } + holder.titleView.text = matrixItem.getBestName() + if (space) { + avatarRenderer.renderSpace(matrixItem, holder.avatarImageView) + } else { + avatarRenderer.render(matrixItem, holder.avatarImageView) + } + + holder.descriptionText.text = span { + span { + apply { + val tintColor = ThemeUtils.getColor(holder.view.context, R.attr.riotx_text_secondary) + ContextCompat.getDrawable(holder.view.context, R.drawable.ic_member_small) + ?.apply { + ThemeUtils.tintDrawableWithColor(this, tintColor) + }?.let { + image(it) + } + } + +" $memberCount" + apply { + topic?.let { + +" - $topic" + } + } + } + } + + holder.joinButton.text = buttonLabel + + if (loading) { + holder.joinButtonLoading.isVisible = true + holder.joinButton.isInvisible = true + } else { + holder.joinButtonLoading.isVisible = false + holder.joinButton.isVisible = true + } + + holder.joinButton.setOnClickListener { + // local echo + holder.joinButton.isEnabled = false + holder.view.postDelayed({ holder.joinButton.isEnabled = true }, 400) + buttonClickListener?.onClick(it) + } + } + + override fun unbind(holder: Holder) { + holder.rootView.setOnClickListener(null) + holder.rootView.setOnLongClickListener(null) + avatarRenderer.clear(holder.avatarImageView) + super.unbind(holder) + } + + class Holder : VectorEpoxyHolder() { + val titleView by bind(R.id.roomNameView) + val joinButton by bind