diff --git a/appnav/build.gradle.kts b/appnav/build.gradle.kts index 8ece3e5841..ae05e104b6 100644 --- a/appnav/build.gradle.kts +++ b/appnav/build.gradle.kts @@ -66,4 +66,7 @@ dependencies { testImplementation(projects.libraries.matrix.test) testImplementation(projects.features.rageshake.test) testImplementation(projects.features.rageshake.impl) + testImplementation(projects.services.appnavstate.test) + testImplementation(libs.test.appyx.junit) + testImplementation(libs.test.arch.core) } diff --git a/appnav/src/main/kotlin/io/element/android/appnav/RoomFlowNode.kt b/appnav/src/main/kotlin/io/element/android/appnav/RoomFlowNode.kt index ba23dd22d4..22929e3d66 100644 --- a/appnav/src/main/kotlin/io/element/android/appnav/RoomFlowNode.kt +++ b/appnav/src/main/kotlin/io/element/android/appnav/RoomFlowNode.kt @@ -83,6 +83,7 @@ class RoomFlowNode @AssistedInject constructor( Timber.v("OnCreate") plugins().forEach { it.onFlowCreated(inputs.room) } appNavigationStateService.onNavigateToRoom(id, inputs.room.roomId) + fetchRoomMembers() }, onDestroy = { Timber.v("OnDestroy") @@ -91,8 +92,6 @@ class RoomFlowNode @AssistedInject constructor( appNavigationStateService.onLeavingRoom(id) } ) - - lifecycleScope.fetchRoomMembers() roomMembershipObserver.updates .filter { update -> update.roomId == inputs.room.roomId && !update.isUserInRoom } .onEach { @@ -101,7 +100,7 @@ class RoomFlowNode @AssistedInject constructor( .launchIn(lifecycleScope) } - private fun CoroutineScope.fetchRoomMembers() = launch { + private fun fetchRoomMembers() = lifecycleScope.launch { val room = inputs.room room.fetchMembers() .onFailure { diff --git a/appnav/src/test/kotlin/io/element/android/appnav/RoomFlowNodeTest.kt b/appnav/src/test/kotlin/io/element/android/appnav/RoomFlowNodeTest.kt new file mode 100644 index 0000000000..ef611a2f4b --- /dev/null +++ b/appnav/src/test/kotlin/io/element/android/appnav/RoomFlowNodeTest.kt @@ -0,0 +1,130 @@ +/* + * Copyright (c) 2023 New Vector Ltd + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package io.element.android.appnav + +import androidx.arch.core.executor.testing.InstantTaskExecutorRule +import androidx.lifecycle.Lifecycle +import com.bumble.appyx.core.modality.BuildContext +import com.bumble.appyx.core.node.Node +import com.bumble.appyx.core.node.node +import com.bumble.appyx.core.plugin.Plugin +import com.bumble.appyx.navmodel.backstack.activeElement +import com.bumble.appyx.testing.junit4.util.MainDispatcherRule +import com.bumble.appyx.testing.unit.common.helper.nodeTestHelper +import com.bumble.appyx.testing.unit.common.helper.parentNodeTestHelper +import com.google.common.truth.Truth +import io.element.android.features.messages.api.MessagesEntryPoint +import io.element.android.features.roomdetails.api.RoomDetailsEntryPoint +import io.element.android.libraries.architecture.childNode +import io.element.android.libraries.matrix.api.room.RoomMembershipObserver +import io.element.android.libraries.matrix.test.room.FakeMatrixRoom +import io.element.android.services.appnavstate.test.NoopAppNavigationStateService +import org.junit.Rule +import org.junit.Test + +class RoomFlowNodeTest { + + @get:Rule + val instantTaskExecutorRule = InstantTaskExecutorRule() + + @get:Rule + val mainDispatcherRule = MainDispatcherRule() + + private class FakeMessagesEntryPoint : MessagesEntryPoint { + + var nodeId: String? = null + var callback: MessagesEntryPoint.Callback? = null + + override fun createNode(parentNode: Node, buildContext: BuildContext, callback: MessagesEntryPoint.Callback): Node { + return node(buildContext) {}.also { + nodeId = it.id + this.callback = callback + } + } + } + + private class FakeRoomDetailsEntryPoint : RoomDetailsEntryPoint { + + var nodeId: String? = null + + override fun createNode(parentNode: Node, buildContext: BuildContext, plugins: List): Node { + return node(buildContext) {}.also { + nodeId = it.id + } + } + } + + private fun aRoomFlowNode( + plugins: List, + messagesEntryPoint: MessagesEntryPoint = FakeMessagesEntryPoint(), + roomDetailsEntryPoint: RoomDetailsEntryPoint = FakeRoomDetailsEntryPoint(), + ) = RoomFlowNode( + buildContext = BuildContext.root(savedStateMap = null), + plugins = plugins, + messagesEntryPoint = messagesEntryPoint, + roomDetailsEntryPoint = roomDetailsEntryPoint, + appNavigationStateService = NoopAppNavigationStateService(), + roomMembershipObserver = RoomMembershipObserver() + ) + + @Test + fun `given a room flow node when initialized then it fetches room members`() { + // GIVEN + val room = FakeMatrixRoom() + val inputs = RoomFlowNode.Inputs(room) + val roomFlowNode = aRoomFlowNode(listOf(inputs)) + Truth.assertThat(room.areMembersFetched).isFalse() + // WHEN + roomFlowNode.nodeTestHelper() + // THEN + Truth.assertThat(room.areMembersFetched).isTrue() + } + + @Test + fun `given a room flow node when initialized then it loads messages entry point`() { + // GIVEN + val room = FakeMatrixRoom() + val fakeMessagesEntryPoint = FakeMessagesEntryPoint() + val inputs = RoomFlowNode.Inputs(room) + val roomFlowNode = aRoomFlowNode(listOf(inputs), fakeMessagesEntryPoint) + // WHEN + val roomFlowNodeTestHelper = roomFlowNode.parentNodeTestHelper() + + // THEN + Truth.assertThat(roomFlowNode.backstack.activeElement).isEqualTo(RoomFlowNode.NavTarget.Messages) + roomFlowNodeTestHelper.assertChildHasLifecycle(RoomFlowNode.NavTarget.Messages, Lifecycle.State.CREATED) + val messagesNode = roomFlowNode.childNode(RoomFlowNode.NavTarget.Messages)!! + Truth.assertThat(messagesNode.id).isEqualTo(fakeMessagesEntryPoint.nodeId) + } + + @Test + fun `given a room flow node when callback on room details is triggered then it loads room details entry point`() { + // GIVEN + val room = FakeMatrixRoom() + val fakeMessagesEntryPoint = FakeMessagesEntryPoint() + val fakeRoomDetailsEntryPoint = FakeRoomDetailsEntryPoint() + val inputs = RoomFlowNode.Inputs(room) + val roomFlowNode = aRoomFlowNode(listOf(inputs), fakeMessagesEntryPoint, fakeRoomDetailsEntryPoint) + val roomFlowNodeTestHelper = roomFlowNode.parentNodeTestHelper() + // WHEN + fakeMessagesEntryPoint.callback?.onRoomDetailsClicked() + // THEN + roomFlowNodeTestHelper.assertChildHasLifecycle(RoomFlowNode.NavTarget.RoomDetails, Lifecycle.State.CREATED) + val roomDetailsNode = roomFlowNode.childNode(RoomFlowNode.NavTarget.RoomDetails)!! + Truth.assertThat(roomDetailsNode.id).isEqualTo(fakeRoomDetailsEntryPoint.nodeId) + } +} diff --git a/features/roomdetails/impl/src/test/kotlin/io/element/android/features/roomdetails/RoomDetailsPresenterTests.kt b/features/roomdetails/impl/src/test/kotlin/io/element/android/features/roomdetails/RoomDetailsPresenterTests.kt index 41404b7354..99e87a64be 100644 --- a/features/roomdetails/impl/src/test/kotlin/io/element/android/features/roomdetails/RoomDetailsPresenterTests.kt +++ b/features/roomdetails/impl/src/test/kotlin/io/element/android/features/roomdetails/RoomDetailsPresenterTests.kt @@ -32,7 +32,6 @@ import io.element.android.libraries.matrix.api.room.RoomMembershipState import io.element.android.libraries.matrix.api.timeline.item.event.MembershipChange import io.element.android.libraries.matrix.test.A_ROOM_ID import io.element.android.libraries.matrix.test.A_ROOM_NAME -import io.element.android.libraries.matrix.test.A_SESSION_ID import io.element.android.libraries.matrix.test.A_USER_ID import io.element.android.libraries.matrix.test.room.FakeMatrixRoom import kotlinx.coroutines.ExperimentalCoroutinesApi @@ -45,7 +44,7 @@ import org.junit.Test @ExperimentalCoroutinesApi class RoomDetailsPresenterTests { - private val roomMembershipObserver = RoomMembershipObserver(A_SESSION_ID) + private val roomMembershipObserver = RoomMembershipObserver() @Test fun `present - initial state is created from room info`() = runTest { diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 2f39ffda8b..509a8614b8 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -103,6 +103,7 @@ network_retrofit_converter_serialization = "com.jakewharton.retrofit:retrofit2-k # Test test_core = { module = "androidx.test:core", version.ref = "test_core" } test_corektx = { module = "androidx.test:core-ktx", version.ref = "test_core" } +test_arch_core = "androidx.arch.core:core-testing:2.2.0" test_junit = "junit:junit:4.13.2" test_runner = "androidx.test:runner:1.5.2" test_uiautomator = "androidx.test.uiautomator:uiautomator:2.2.0" @@ -115,6 +116,7 @@ test_turbine = "app.cash.turbine:turbine:0.12.1" test_truth = "com.google.truth:truth:1.1.3" test_parameter_injector = "com.google.testparameterinjector:test-parameter-injector:1.11" test_robolectric = "org.robolectric:robolectric:4.9.2" +test_appyx_junit = { module = "com.bumble.appyx:testing-junit4", version.ref = "appyx" } # Others coil = { module = "io.coil-kt:coil", version.ref = "coil" } diff --git a/libraries/architecture/src/main/kotlin/io/element/android/libraries/architecture/ParentNodeExt.kt b/libraries/architecture/src/main/kotlin/io/element/android/libraries/architecture/ParentNodeExt.kt new file mode 100644 index 0000000000..b8284ff7b9 --- /dev/null +++ b/libraries/architecture/src/main/kotlin/io/element/android/libraries/architecture/ParentNodeExt.kt @@ -0,0 +1,27 @@ +/* + * Copyright (c) 2023 New Vector Ltd + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package io.element.android.libraries.architecture + +import com.bumble.appyx.core.children.nodeOrNull +import com.bumble.appyx.core.node.Node +import com.bumble.appyx.core.node.ParentNode + +fun ParentNode.childNode(navTarget: NavTarget): Node? { + val childMap = children.value + val key = childMap.keys.find { it.navTarget == navTarget } + return childMap[key]?.nodeOrNull +} diff --git a/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/room/RoomMembershipObserver.kt b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/room/RoomMembershipObserver.kt index 42b0996bb1..ed6f3fae26 100644 --- a/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/room/RoomMembershipObserver.kt +++ b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/room/RoomMembershipObserver.kt @@ -17,14 +17,11 @@ package io.element.android.libraries.matrix.api.room import io.element.android.libraries.matrix.api.core.RoomId -import io.element.android.libraries.matrix.api.core.SessionId import io.element.android.libraries.matrix.api.timeline.item.event.MembershipChange import kotlinx.coroutines.flow.MutableSharedFlow import kotlinx.coroutines.flow.asSharedFlow -class RoomMembershipObserver( - private val sessionId: SessionId, -) { +class RoomMembershipObserver { data class RoomMembershipUpdate( val roomId: RoomId, val isUserInRoom: Boolean, diff --git a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/RustMatrixClient.kt b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/RustMatrixClient.kt index d2408abdd7..a2fe3f872a 100644 --- a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/RustMatrixClient.kt +++ b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/RustMatrixClient.kt @@ -142,7 +142,7 @@ class RustMatrixClient constructor( private val mediaResolver = RustMediaResolver(this) private val isSyncing = AtomicBoolean(false) - private val roomMembershipObserver = RoomMembershipObserver(sessionId) + private val roomMembershipObserver = RoomMembershipObserver() init { client.setDelegate(clientDelegate) diff --git a/libraries/matrix/test/src/main/kotlin/io/element/android/libraries/matrix/test/FakeMatrixClient.kt b/libraries/matrix/test/src/main/kotlin/io/element/android/libraries/matrix/test/FakeMatrixClient.kt index 7159eff417..b570e9d9ae 100644 --- a/libraries/matrix/test/src/main/kotlin/io/element/android/libraries/matrix/test/FakeMatrixClient.kt +++ b/libraries/matrix/test/src/main/kotlin/io/element/android/libraries/matrix/test/FakeMatrixClient.kt @@ -104,7 +104,7 @@ class FakeMatrixClient( override fun onSlidingSyncUpdate() {} override fun roomMembershipObserver(): RoomMembershipObserver { - return RoomMembershipObserver(A_SESSION_ID) + return RoomMembershipObserver() } // Mocks diff --git a/services/appnavstate/impl/build.gradle.kts b/services/appnavstate/impl/build.gradle.kts index 253ddec2fb..eb34a3116a 100644 --- a/services/appnavstate/impl/build.gradle.kts +++ b/services/appnavstate/impl/build.gradle.kts @@ -47,4 +47,5 @@ dependencies { testImplementation(libs.coroutines.test) testImplementation(libs.test.truth) testImplementation(projects.libraries.matrix.test) + testImplementation(projects.services.appnavstate.test) } diff --git a/services/appnavstate/impl/src/test/kotlin/io/element/android/services/appnavstate/impl/DefaultAppNavigationStateServiceTest.kt b/services/appnavstate/impl/src/test/kotlin/io/element/android/services/appnavstate/impl/DefaultAppNavigationStateServiceTest.kt index e240ada2b2..1ccd4c5b7a 100644 --- a/services/appnavstate/impl/src/test/kotlin/io/element/android/services/appnavstate/impl/DefaultAppNavigationStateServiceTest.kt +++ b/services/appnavstate/impl/src/test/kotlin/io/element/android/services/appnavstate/impl/DefaultAppNavigationStateServiceTest.kt @@ -24,37 +24,35 @@ import io.element.android.libraries.matrix.test.A_SESSION_ID import io.element.android.libraries.matrix.test.A_SPACE_ID import io.element.android.libraries.matrix.test.A_THREAD_ID import io.element.android.services.appnavstate.api.AppNavigationState +import io.element.android.services.appnavstate.test.A_ROOM_OWNER +import io.element.android.services.appnavstate.test.A_SESSION_OWNER +import io.element.android.services.appnavstate.test.A_SPACE_OWNER +import io.element.android.services.appnavstate.test.A_THREAD_OWNER import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.flow.first import kotlinx.coroutines.test.runTest import org.junit.Assert.assertThrows import org.junit.Test - -private const val aSessionOwner = "aSessionOwner" -private const val aSpaceOwner = "aSpaceOwner" -private const val aRoomOwner = "aRoomOwner" -private const val aThreadOwner = "aThreadOwner" - class DefaultAppNavigationStateServiceTest { @Test fun testNavigation() = runTest { val service = DefaultAppNavigationStateService() - service.onNavigateToSession(aSessionOwner, A_SESSION_ID) - service.onNavigateToSpace(aSpaceOwner, A_SPACE_ID) - service.onNavigateToRoom(aRoomOwner, A_ROOM_ID) - service.onNavigateToThread(aThreadOwner, A_THREAD_ID) + service.onNavigateToSession(A_SESSION_OWNER, A_SESSION_ID) + service.onNavigateToSpace(A_SPACE_OWNER, A_SPACE_ID) + service.onNavigateToRoom(A_ROOM_OWNER, A_ROOM_ID) + service.onNavigateToThread(A_THREAD_OWNER, A_THREAD_ID) assertThat(service.appNavigationStateFlow.first()).isEqualTo( AppNavigationState.Thread( - aThreadOwner, A_THREAD_ID, + A_THREAD_OWNER, A_THREAD_ID, AppNavigationState.Room( - aRoomOwner, + A_ROOM_OWNER, A_ROOM_ID, AppNavigationState.Space( - aSpaceOwner, + A_SPACE_OWNER, A_SPACE_ID, AppNavigationState.Session( - aSessionOwner, + A_SESSION_OWNER, A_SESSION_ID ) ) @@ -66,6 +64,6 @@ class DefaultAppNavigationStateServiceTest { @Test fun testFailure() = runTest { val service = DefaultAppNavigationStateService() - assertThrows(IllegalStateException::class.java) { service.onNavigateToSpace(aSpaceOwner, A_SPACE_ID) } + assertThrows(IllegalStateException::class.java) { service.onNavigateToSpace(A_SPACE_OWNER, A_SPACE_ID) } } } diff --git a/services/appnavstate/test/build.gradle.kts b/services/appnavstate/test/build.gradle.kts index cda023b236..57dda0d29e 100644 --- a/services/appnavstate/test/build.gradle.kts +++ b/services/appnavstate/test/build.gradle.kts @@ -27,4 +27,5 @@ android { dependencies { api(projects.libraries.matrix.api) api(projects.services.appnavstate.api) + implementation(libs.coroutines.core) } diff --git a/services/appnavstate/test/src/main/kotlin/io/element/android/services/appnavstate/test/AppNavStateFixture.kt b/services/appnavstate/test/src/main/kotlin/io/element/android/services/appnavstate/test/AppNavStateFixture.kt index 0ac6526ee2..aa0b351220 100644 --- a/services/appnavstate/test/src/main/kotlin/io/element/android/services/appnavstate/test/AppNavStateFixture.kt +++ b/services/appnavstate/test/src/main/kotlin/io/element/android/services/appnavstate/test/AppNavStateFixture.kt @@ -23,27 +23,31 @@ import io.element.android.libraries.matrix.api.core.SpaceId import io.element.android.libraries.matrix.api.core.ThreadId import io.element.android.services.appnavstate.api.AppNavigationState +const val A_SESSION_OWNER = "aSessionOwner" +const val A_SPACE_OWNER = "aSpaceOwner" +const val A_ROOM_OWNER = "aRoomOwner" +const val A_THREAD_OWNER = "aThreadOwner" + fun anAppNavigationState( sessionId: SessionId? = null, spaceId: SpaceId? = MAIN_SPACE, roomId: RoomId? = null, threadId: ThreadId? = null, - owner: String = "a-owner", ): AppNavigationState { if (sessionId == null) { return AppNavigationState.Root } - val session = AppNavigationState.Session(owner, sessionId) + val session = AppNavigationState.Session(A_SESSION_OWNER, sessionId) if (spaceId == null) { return session } - val space = AppNavigationState.Space(owner, spaceId, session) + val space = AppNavigationState.Space(A_SPACE_OWNER, spaceId, session) if (roomId == null) { return space } - val room = AppNavigationState.Room(owner, roomId, space) + val room = AppNavigationState.Room(A_ROOM_OWNER, roomId, space) if (threadId == null) { return room } - return AppNavigationState.Thread(owner, threadId, room) + return AppNavigationState.Thread(A_THREAD_OWNER, threadId, room) } diff --git a/services/appnavstate/test/src/main/kotlin/io/element/android/services/appnavstate/test/NoopAppNavigationStateService.kt b/services/appnavstate/test/src/main/kotlin/io/element/android/services/appnavstate/test/NoopAppNavigationStateService.kt new file mode 100644 index 0000000000..c31d74ec18 --- /dev/null +++ b/services/appnavstate/test/src/main/kotlin/io/element/android/services/appnavstate/test/NoopAppNavigationStateService.kt @@ -0,0 +1,48 @@ +/* + * Copyright (c) 2023 New Vector Ltd + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package io.element.android.services.appnavstate.test + +import io.element.android.libraries.matrix.api.core.RoomId +import io.element.android.libraries.matrix.api.core.SessionId +import io.element.android.libraries.matrix.api.core.SpaceId +import io.element.android.libraries.matrix.api.core.ThreadId +import io.element.android.services.appnavstate.api.AppNavigationState +import io.element.android.services.appnavstate.api.AppNavigationStateService +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow + +class NoopAppNavigationStateService : AppNavigationStateService { + + private val currentAppNavigationState: MutableStateFlow = + MutableStateFlow(AppNavigationState.Root) + override val appNavigationStateFlow: StateFlow = currentAppNavigationState + + override fun onNavigateToSession(owner: String, sessionId: SessionId) = Unit + override fun onLeavingSession(owner: String) = Unit + + override fun onNavigateToSpace(owner: String, spaceId: SpaceId) = Unit + + override fun onLeavingSpace(owner: String) = Unit + + override fun onNavigateToRoom(owner: String, roomId: RoomId) = Unit + + override fun onLeavingRoom(owner: String) = Unit + + override fun onNavigateToThread(owner: String, threadId: ThreadId) = Unit + + override fun onLeavingThread(owner: String) = Unit +}