From 08897522390f771ee6c8d003091cf87e9588914b Mon Sep 17 00:00:00 2001 From: Benoit Marty Date: Mon, 6 Feb 2023 12:39:36 +0100 Subject: [PATCH 01/51] Cleanup --- .../io/element/android/features/roomlist/RoomListPresenter.kt | 2 -- .../element/android/features/roomlist/model/RoomListState.kt | 1 - .../android/features/roomlist/RoomListPresenterTests.kt | 4 +++- 3 files changed, 3 insertions(+), 4 deletions(-) diff --git a/features/roomlist/src/main/kotlin/io/element/android/features/roomlist/RoomListPresenter.kt b/features/roomlist/src/main/kotlin/io/element/android/features/roomlist/RoomListPresenter.kt index b28504eee1..c684b0dd45 100644 --- a/features/roomlist/src/main/kotlin/io/element/android/features/roomlist/RoomListPresenter.kt +++ b/features/roomlist/src/main/kotlin/io/element/android/features/roomlist/RoomListPresenter.kt @@ -57,7 +57,6 @@ class RoomListPresenter @Inject constructor( mutableStateOf(null) } var filter by rememberSaveable { mutableStateOf("") } - val isLoginOut = rememberSaveable { mutableStateOf(false) } val roomSummaries by client .roomSummaryDataSource() .roomSummaries() @@ -86,7 +85,6 @@ class RoomListPresenter @Inject constructor( matrixUser = matrixUser.value, roomList = filteredRoomSummaries.value, filter = filter, - isLoginOut = isLoginOut.value, eventSink = ::handleEvents ) } diff --git a/features/roomlist/src/main/kotlin/io/element/android/features/roomlist/model/RoomListState.kt b/features/roomlist/src/main/kotlin/io/element/android/features/roomlist/model/RoomListState.kt index f2d873654b..e9a48a7249 100644 --- a/features/roomlist/src/main/kotlin/io/element/android/features/roomlist/model/RoomListState.kt +++ b/features/roomlist/src/main/kotlin/io/element/android/features/roomlist/model/RoomListState.kt @@ -25,6 +25,5 @@ data class RoomListState( val matrixUser: MatrixUser?, val roomList: ImmutableList, val filter: String, - val isLoginOut: Boolean, val eventSink: (RoomListEvents) -> Unit ) diff --git a/features/roomlist/src/test/kotlin/io/element/android/features/roomlist/RoomListPresenterTests.kt b/features/roomlist/src/test/kotlin/io/element/android/features/roomlist/RoomListPresenterTests.kt index 615353b7e8..1f2d265cc8 100644 --- a/features/roomlist/src/test/kotlin/io/element/android/features/roomlist/RoomListPresenterTests.kt +++ b/features/roomlist/src/test/kotlin/io/element/android/features/roomlist/RoomListPresenterTests.kt @@ -14,6 +14,8 @@ * limitations under the License. */ +@file:OptIn(ExperimentalCoroutinesApi::class) + package io.element.android.features.roomlist import app.cash.molecule.RecompositionClock @@ -22,6 +24,7 @@ import app.cash.turbine.test import com.google.common.truth.Truth.assertThat import io.element.android.libraries.matrix.core.SessionId import io.element.android.libraries.matrixtest.FakeMatrixClient +import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.test.runTest import org.junit.Test @@ -29,7 +32,6 @@ class RoomListPresenterTests { @Test fun `present - should start with no user and then load user with success`() = runTest { - val presenter = RoomListPresenter( FakeMatrixClient( SessionId("sessionId") From d6afb97aaca91d9d15d29feff1092a750724441e Mon Sep 17 00:00:00 2001 From: Benoit Marty Date: Mon, 6 Feb 2023 12:42:49 +0100 Subject: [PATCH 02/51] Fix first test. --- .../element/android/features/roomlist/RoomListPresenterTests.kt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/features/roomlist/src/test/kotlin/io/element/android/features/roomlist/RoomListPresenterTests.kt b/features/roomlist/src/test/kotlin/io/element/android/features/roomlist/RoomListPresenterTests.kt index 1f2d265cc8..6a238f501e 100644 --- a/features/roomlist/src/test/kotlin/io/element/android/features/roomlist/RoomListPresenterTests.kt +++ b/features/roomlist/src/test/kotlin/io/element/android/features/roomlist/RoomListPresenterTests.kt @@ -43,7 +43,7 @@ class RoomListPresenterTests { val initialState = awaitItem() assertThat(initialState.matrixUser).isNull() val withUserState = awaitItem() - assertThat(withUserState).isNotNull() + assertThat(withUserState.matrixUser).isNotNull() } } } From b0b38598c62040e719d0762e9481b0412b2b4105 Mon Sep 17 00:00:00 2001 From: Benoit Marty Date: Mon, 6 Feb 2023 12:59:14 +0100 Subject: [PATCH 03/51] Test filter effect. --- .../roomlist/RoomListPresenterTests.kt | 24 ++++++++++++++++++- 1 file changed, 23 insertions(+), 1 deletion(-) diff --git a/features/roomlist/src/test/kotlin/io/element/android/features/roomlist/RoomListPresenterTests.kt b/features/roomlist/src/test/kotlin/io/element/android/features/roomlist/RoomListPresenterTests.kt index 6a238f501e..70fc4f22e4 100644 --- a/features/roomlist/src/test/kotlin/io/element/android/features/roomlist/RoomListPresenterTests.kt +++ b/features/roomlist/src/test/kotlin/io/element/android/features/roomlist/RoomListPresenterTests.kt @@ -22,6 +22,7 @@ import app.cash.molecule.RecompositionClock import app.cash.molecule.moleculeFlow import app.cash.turbine.test import com.google.common.truth.Truth.assertThat +import io.element.android.features.roomlist.model.RoomListEvents import io.element.android.libraries.matrix.core.SessionId import io.element.android.libraries.matrixtest.FakeMatrixClient import kotlinx.coroutines.ExperimentalCoroutinesApi @@ -35,7 +36,8 @@ class RoomListPresenterTests { val presenter = RoomListPresenter( FakeMatrixClient( SessionId("sessionId") - ), LastMessageFormatter() + ), + LastMessageFormatter() ) moleculeFlow(RecompositionClock.Immediate) { presenter.present() @@ -46,4 +48,24 @@ class RoomListPresenterTests { assertThat(withUserState.matrixUser).isNotNull() } } + + @Test + fun `present - should filter room with success`() = runTest { + val presenter = RoomListPresenter( + FakeMatrixClient( + SessionId("sessionId") + ), + LastMessageFormatter() + ) + moleculeFlow(RecompositionClock.Immediate) { + presenter.present() + }.test { + var initialState = awaitItem() + val withUserState = awaitItem() + assertThat(withUserState.filter).isEqualTo("") + withUserState.eventSink.invoke(RoomListEvents.UpdateFilter("t")) + val withFilterState = awaitItem() + assertThat(withFilterState.filter).isEqualTo("t") + } + } } From e41e6b9205ac86cfa8f8dde02e5fe1f5c48636de Mon Sep 17 00:00:00 2001 From: Benoit Marty Date: Mon, 6 Feb 2023 15:59:13 +0100 Subject: [PATCH 04/51] Fix bug. n+1 items were created. --- .../roomlist/model/RoomListRoomSummaryPlaceholders.kt | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/features/roomlist/src/main/kotlin/io/element/android/features/roomlist/model/RoomListRoomSummaryPlaceholders.kt b/features/roomlist/src/main/kotlin/io/element/android/features/roomlist/model/RoomListRoomSummaryPlaceholders.kt index 5e6176bcb2..5fb3221093 100644 --- a/features/roomlist/src/main/kotlin/io/element/android/features/roomlist/model/RoomListRoomSummaryPlaceholders.kt +++ b/features/roomlist/src/main/kotlin/io/element/android/features/roomlist/model/RoomListRoomSummaryPlaceholders.kt @@ -33,8 +33,8 @@ object RoomListRoomSummaryPlaceholders { fun createFakeList(size: Int): List { return mutableListOf().apply { - for (i in 0..size) { - add(create("\$fakeRoom$i")) + repeat(size) { + add(create("\$fakeRoom$it")) } } } From 32dd9adda716ecc310ea89867be122a536491b92 Mon Sep 17 00:00:00 2001 From: Benoit Marty Date: Mon, 6 Feb 2023 16:27:19 +0100 Subject: [PATCH 05/51] Enable `testFixtures` - but not supported by AGP yet, so put files in module `matrixtest` --- features/roomlist/build.gradle.kts | 2 + .../roomlist/RoomListPresenterTests.kt | 51 ++++++++++++++- .../template/TemplatePresenterTests.kt | 3 + gradle.properties | 3 + .../libraries/matrixtest/FakeMatrixClient.kt | 7 ++- .../matrixtest/core/RoomIdFixture.kt | 22 +++++++ .../room/InMemoryRoomSummaryDataSource.kt | 8 ++- .../matrixtest/room/RoomSummaryFixture.kt | 63 +++++++++++++++++++ .../main/kotlin/extension/CommonExtension.kt | 2 + 9 files changed, 157 insertions(+), 4 deletions(-) create mode 100644 libraries/matrixtest/src/main/kotlin/io/element/android/libraries/matrixtest/core/RoomIdFixture.kt create mode 100644 libraries/matrixtest/src/main/kotlin/io/element/android/libraries/matrixtest/room/RoomSummaryFixture.kt diff --git a/features/roomlist/build.gradle.kts b/features/roomlist/build.gradle.kts index fc0402eafc..de67bb010f 100644 --- a/features/roomlist/build.gradle.kts +++ b/features/roomlist/build.gradle.kts @@ -51,6 +51,8 @@ dependencies { testImplementation(libs.test.turbine) testImplementation(projects.libraries.matrixtest) + testImplementation(testFixtures(projects.libraries.matrix)) + androidTestImplementation(libs.test.junitext) ksp(libs.showkase.processor) diff --git a/features/roomlist/src/test/kotlin/io/element/android/features/roomlist/RoomListPresenterTests.kt b/features/roomlist/src/test/kotlin/io/element/android/features/roomlist/RoomListPresenterTests.kt index 70fc4f22e4..8bd6efa167 100644 --- a/features/roomlist/src/test/kotlin/io/element/android/features/roomlist/RoomListPresenterTests.kt +++ b/features/roomlist/src/test/kotlin/io/element/android/features/roomlist/RoomListPresenterTests.kt @@ -23,11 +23,20 @@ import app.cash.molecule.moleculeFlow import app.cash.turbine.test import com.google.common.truth.Truth.assertThat import io.element.android.features.roomlist.model.RoomListEvents +import io.element.android.features.roomlist.model.RoomListRoomSummary +import io.element.android.libraries.designsystem.components.avatar.AvatarData import io.element.android.libraries.matrix.core.SessionId import io.element.android.libraries.matrixtest.FakeMatrixClient +import io.element.android.libraries.matrixtest.core.A_ROOM_ID +import io.element.android.libraries.matrixtest.core.A_ROOM_ID_VALUE +import io.element.android.libraries.matrixtest.room.A_LAST_MESSAGE +import io.element.android.libraries.matrixtest.room.A_ROOM_NAME +import io.element.android.libraries.matrixtest.room.InMemoryRoomSummaryDataSource +import io.element.android.libraries.matrixtest.room.aRoomSummaryFilled import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.test.runTest import org.junit.Test +import timber.log.Timber class RoomListPresenterTests { @@ -60,7 +69,7 @@ class RoomListPresenterTests { moleculeFlow(RecompositionClock.Immediate) { presenter.present() }.test { - var initialState = awaitItem() + skipItems(1) val withUserState = awaitItem() assertThat(withUserState.filter).isEqualTo("") withUserState.eventSink.invoke(RoomListEvents.UpdateFilter("t")) @@ -68,4 +77,44 @@ class RoomListPresenterTests { assertThat(withFilterState.filter).isEqualTo("t") } } + + @Test + fun `present - load 1 room with success`() = runTest { + val roomSummaryDataSource = InMemoryRoomSummaryDataSource() + val presenter = RoomListPresenter( + FakeMatrixClient( + sessionId = SessionId("sessionId"), + roomSummaryDataSource = roomSummaryDataSource + ), + LastMessageFormatter() + ) + moleculeFlow(RecompositionClock.Immediate) { + presenter.present() + }.test { + skipItems(1) + val withUserState = awaitItem() + // Room list is loaded with 16 placeholders + assertThat(withUserState.roomList.size).isEqualTo(16) + assertThat(withUserState.roomList.all { it.isPlaceholder }).isTrue() + val roomSummary = aRoomSummaryFilled() + roomSummaryDataSource.postRoomSummary( + listOf(roomSummary) + ) + skipItems(1) + val withRoomState = awaitItem() + assertThat(withRoomState.roomList.size).isEqualTo(1) + assertThat(withRoomState.roomList.first()).isEqualTo( + RoomListRoomSummary( + id = A_ROOM_ID_VALUE, + roomId = A_ROOM_ID, + name = A_ROOM_NAME, + hasUnread = true, + timestamp = "", + lastMessage = A_LAST_MESSAGE, + avatarData = AvatarData(name = A_ROOM_NAME), + isPlaceholder = false, + ) + ) + } + } } diff --git a/features/template/src/test/kotlin/io/element/android/features/template/TemplatePresenterTests.kt b/features/template/src/test/kotlin/io/element/android/features/template/TemplatePresenterTests.kt index 39b7e32ea8..34cc73ba53 100644 --- a/features/template/src/test/kotlin/io/element/android/features/template/TemplatePresenterTests.kt +++ b/features/template/src/test/kotlin/io/element/android/features/template/TemplatePresenterTests.kt @@ -14,12 +14,15 @@ * limitations under the License. */ +@file:OptIn(ExperimentalCoroutinesApi::class) + package io.element.android.features.template import app.cash.molecule.RecompositionClock import app.cash.molecule.moleculeFlow import app.cash.turbine.test import com.google.common.truth.Truth +import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.test.runTest import org.junit.Test diff --git a/gradle.properties b/gradle.properties index 6902acf2bd..df832c13ba 100644 --- a/gradle.properties +++ b/gradle.properties @@ -47,3 +47,6 @@ signing.element.nightly.keyPassword=Secret # Customise the Lint version to use a more recent version than the one bundled with AGP # https://googlesamples.github.io/android-custom-lint-rules/usage/newer-lint.md.html android.experimental.lint.version=8.0.0-alpha10 + +# Enable test fixture for all modules by default +android.experimental.enableTestFixtures=true diff --git a/libraries/matrixtest/src/main/kotlin/io/element/android/libraries/matrixtest/FakeMatrixClient.kt b/libraries/matrixtest/src/main/kotlin/io/element/android/libraries/matrixtest/FakeMatrixClient.kt index 4593fe252a..dd0181f503 100644 --- a/libraries/matrixtest/src/main/kotlin/io/element/android/libraries/matrixtest/FakeMatrixClient.kt +++ b/libraries/matrixtest/src/main/kotlin/io/element/android/libraries/matrixtest/FakeMatrixClient.kt @@ -28,7 +28,10 @@ import io.element.android.libraries.matrixtest.room.FakeMatrixRoom import io.element.android.libraries.matrixtest.room.InMemoryRoomSummaryDataSource import org.matrix.rustcomponents.sdk.MediaSource -class FakeMatrixClient(override val sessionId: SessionId) : MatrixClient { +class FakeMatrixClient( + override val sessionId: SessionId, + val roomSummaryDataSource: RoomSummaryDataSource = InMemoryRoomSummaryDataSource() +) : MatrixClient { override fun getRoom(roomId: RoomId): MatrixRoom? { return FakeMatrixRoom(roomId) @@ -39,7 +42,7 @@ class FakeMatrixClient(override val sessionId: SessionId) : MatrixClient { override fun stopSync() = Unit override fun roomSummaryDataSource(): RoomSummaryDataSource { - return InMemoryRoomSummaryDataSource() + return roomSummaryDataSource } override fun mediaResolver(): MediaResolver { diff --git a/libraries/matrixtest/src/main/kotlin/io/element/android/libraries/matrixtest/core/RoomIdFixture.kt b/libraries/matrixtest/src/main/kotlin/io/element/android/libraries/matrixtest/core/RoomIdFixture.kt new file mode 100644 index 0000000000..5b62ad383a --- /dev/null +++ b/libraries/matrixtest/src/main/kotlin/io/element/android/libraries/matrixtest/core/RoomIdFixture.kt @@ -0,0 +1,22 @@ +/* + * 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.matrixtest.core + +import io.element.android.libraries.matrix.core.RoomId + +const val A_ROOM_ID_VALUE = "!aRoomId" +val A_ROOM_ID = RoomId(A_ROOM_ID_VALUE) diff --git a/libraries/matrixtest/src/main/kotlin/io/element/android/libraries/matrixtest/room/InMemoryRoomSummaryDataSource.kt b/libraries/matrixtest/src/main/kotlin/io/element/android/libraries/matrixtest/room/InMemoryRoomSummaryDataSource.kt index 5179e911ab..666f64acb4 100644 --- a/libraries/matrixtest/src/main/kotlin/io/element/android/libraries/matrixtest/room/InMemoryRoomSummaryDataSource.kt +++ b/libraries/matrixtest/src/main/kotlin/io/element/android/libraries/matrixtest/room/InMemoryRoomSummaryDataSource.kt @@ -23,8 +23,14 @@ import kotlinx.coroutines.flow.StateFlow class InMemoryRoomSummaryDataSource : RoomSummaryDataSource { + private val roomSummariesFlow = MutableStateFlow>(emptyList()) + + suspend fun postRoomSummary(roomSummaries: List) { + roomSummariesFlow.emit(roomSummaries) + } + override fun roomSummaries(): StateFlow> { - return MutableStateFlow(emptyList()) + return roomSummariesFlow } override fun setSlidingSyncRange(range: IntRange) = Unit diff --git a/libraries/matrixtest/src/main/kotlin/io/element/android/libraries/matrixtest/room/RoomSummaryFixture.kt b/libraries/matrixtest/src/main/kotlin/io/element/android/libraries/matrixtest/room/RoomSummaryFixture.kt new file mode 100644 index 0000000000..0bed866300 --- /dev/null +++ b/libraries/matrixtest/src/main/kotlin/io/element/android/libraries/matrixtest/room/RoomSummaryFixture.kt @@ -0,0 +1,63 @@ +/* + * 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.matrixtest.room + +import io.element.android.libraries.matrix.core.RoomId +import io.element.android.libraries.matrix.room.RoomSummary +import io.element.android.libraries.matrix.room.RoomSummaryDetails +import io.element.android.libraries.matrixtest.core.A_ROOM_ID + +const val A_ROOM_NAME = "aRoomName" +const val A_LAST_MESSAGE = "Last message" + +fun aRoomSummaryFilled( + roomId: RoomId = A_ROOM_ID, + name: String = A_ROOM_NAME, + isDirect: Boolean = false, + avatarURLString: String? = null, + lastMessage: CharSequence? = A_LAST_MESSAGE, + lastMessageTimestamp: Long? = null, + unreadNotificationCount: Int = 2, +) = RoomSummary.Filled( + aRoomSummaryDetail( + roomId = roomId, + name = name, + isDirect = isDirect, + avatarURLString = avatarURLString, + lastMessage = lastMessage, + lastMessageTimestamp = lastMessageTimestamp, + unreadNotificationCount = unreadNotificationCount, + ) +) + +fun aRoomSummaryDetail( + roomId: RoomId = A_ROOM_ID, + name: String = A_ROOM_NAME, + isDirect: Boolean = false, + avatarURLString: String? = null, + lastMessage: CharSequence? = A_LAST_MESSAGE, + lastMessageTimestamp: Long? = null, + unreadNotificationCount: Int = 2, +) = RoomSummaryDetails( + roomId = roomId, + name = name, + isDirect = isDirect, + avatarURLString = avatarURLString, + lastMessage = lastMessage, + lastMessageTimestamp = lastMessageTimestamp, + unreadNotificationCount = unreadNotificationCount, +) diff --git a/plugins/src/main/kotlin/extension/CommonExtension.kt b/plugins/src/main/kotlin/extension/CommonExtension.kt index f3ba843ab7..5fdb80ba1a 100644 --- a/plugins/src/main/kotlin/extension/CommonExtension.kt +++ b/plugins/src/main/kotlin/extension/CommonExtension.kt @@ -40,6 +40,7 @@ fun CommonExtension<*, *, *, *>.androidConfig(project: Project) { lintConfig = File("${project.rootDir}/tools/lint/lint.xml") checkDependencies = true abortOnError = true + ignoreTestFixturesSources = true } } @@ -64,6 +65,7 @@ fun CommonExtension<*, *, *, *>.composeConfig() { // Disabled until lint stops inspecting generated ksp files... // error.add("ComposableLambdaParameterNaming") error.add("ComposableLambdaParameterPosition") + ignoreTestFixturesSources = true } } From 0082019b1c7959fe220fca95be8ab94ec492b3ac Mon Sep 17 00:00:00 2001 From: Benoit Marty Date: Mon, 6 Feb 2023 17:41:35 +0100 Subject: [PATCH 06/51] Test room filtering. --- .../roomlist/RoomListPresenterTests.kt | 61 +++++++++++++------ 1 file changed, 44 insertions(+), 17 deletions(-) diff --git a/features/roomlist/src/test/kotlin/io/element/android/features/roomlist/RoomListPresenterTests.kt b/features/roomlist/src/test/kotlin/io/element/android/features/roomlist/RoomListPresenterTests.kt index 8bd6efa167..19b9a2755a 100644 --- a/features/roomlist/src/test/kotlin/io/element/android/features/roomlist/RoomListPresenterTests.kt +++ b/features/roomlist/src/test/kotlin/io/element/android/features/roomlist/RoomListPresenterTests.kt @@ -36,7 +36,6 @@ import io.element.android.libraries.matrixtest.room.aRoomSummaryFilled import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.test.runTest import org.junit.Test -import timber.log.Timber class RoomListPresenterTests { @@ -96,25 +95,53 @@ class RoomListPresenterTests { // Room list is loaded with 16 placeholders assertThat(withUserState.roomList.size).isEqualTo(16) assertThat(withUserState.roomList.all { it.isPlaceholder }).isTrue() - val roomSummary = aRoomSummaryFilled() - roomSummaryDataSource.postRoomSummary( - listOf(roomSummary) - ) + roomSummaryDataSource.postRoomSummary(listOf(aRoomSummaryFilled())) skipItems(1) val withRoomState = awaitItem() assertThat(withRoomState.roomList.size).isEqualTo(1) - assertThat(withRoomState.roomList.first()).isEqualTo( - RoomListRoomSummary( - id = A_ROOM_ID_VALUE, - roomId = A_ROOM_ID, - name = A_ROOM_NAME, - hasUnread = true, - timestamp = "", - lastMessage = A_LAST_MESSAGE, - avatarData = AvatarData(name = A_ROOM_NAME), - isPlaceholder = false, - ) - ) + assertThat(withRoomState.roomList.first()).isEqualTo(aRoomListRoomSummary) + } + } + + @Test + fun `present - load 1 room with success and filter rooms`() = runTest { + val roomSummaryDataSource = InMemoryRoomSummaryDataSource() + val presenter = RoomListPresenter( + FakeMatrixClient( + sessionId = SessionId("sessionId"), + roomSummaryDataSource = roomSummaryDataSource + ), + LastMessageFormatter() + ) + moleculeFlow(RecompositionClock.Immediate) { + presenter.present() + }.test { + roomSummaryDataSource.postRoomSummary(listOf(aRoomSummaryFilled())) + skipItems(3) + val loadedState = awaitItem() + // Test filtering with result + loadedState.eventSink.invoke(RoomListEvents.UpdateFilter(A_ROOM_NAME.substring(0, 3))) + val withNotFilteredRoomState = awaitItem() + assertThat(withNotFilteredRoomState.filter).isEqualTo(A_ROOM_NAME.substring(0, 3)) + assertThat(withNotFilteredRoomState.roomList.size).isEqualTo(1) + assertThat(withNotFilteredRoomState.roomList.first()).isEqualTo(aRoomListRoomSummary) + // Test filtering without result + withNotFilteredRoomState.eventSink.invoke(RoomListEvents.UpdateFilter("tada")) + skipItems(1) // Filter update + val withFilteredRoomState = awaitItem() + assertThat(withFilteredRoomState.filter).isEqualTo("tada") + assertThat(withFilteredRoomState.roomList.size).isEqualTo(0) } } } + +private val aRoomListRoomSummary = RoomListRoomSummary( + id = A_ROOM_ID_VALUE, + roomId = A_ROOM_ID, + name = A_ROOM_NAME, + hasUnread = true, + timestamp = "", + lastMessage = A_LAST_MESSAGE, + avatarData = AvatarData(name = A_ROOM_NAME), + isPlaceholder = false, +) From 2516d0cae7e8889714121055ecb0ca9577fddfad Mon Sep 17 00:00:00 2001 From: Benoit Marty Date: Mon, 6 Feb 2023 17:52:29 +0100 Subject: [PATCH 07/51] Add test about visible range --- .../roomlist/RoomListPresenterTests.kt | 37 +++++++++++++++++++ .../room/InMemoryRoomSummaryDataSource.kt | 6 ++- 2 files changed, 42 insertions(+), 1 deletion(-) diff --git a/features/roomlist/src/test/kotlin/io/element/android/features/roomlist/RoomListPresenterTests.kt b/features/roomlist/src/test/kotlin/io/element/android/features/roomlist/RoomListPresenterTests.kt index 19b9a2755a..7af95b0196 100644 --- a/features/roomlist/src/test/kotlin/io/element/android/features/roomlist/RoomListPresenterTests.kt +++ b/features/roomlist/src/test/kotlin/io/element/android/features/roomlist/RoomListPresenterTests.kt @@ -133,6 +133,43 @@ class RoomListPresenterTests { assertThat(withFilteredRoomState.roomList.size).isEqualTo(0) } } + + @Test + fun `present - update visible range`() = runTest { + val roomSummaryDataSource = InMemoryRoomSummaryDataSource() + val presenter = RoomListPresenter( + FakeMatrixClient( + sessionId = SessionId("sessionId"), + roomSummaryDataSource = roomSummaryDataSource + ), + LastMessageFormatter() + ) + moleculeFlow(RecompositionClock.Immediate) { + presenter.present() + }.test { + roomSummaryDataSource.postRoomSummary(listOf(aRoomSummaryFilled())) + skipItems(3) + val loadedState = awaitItem() + // check initial value + assertThat(roomSummaryDataSource.latestSlidingSyncRange).isNull() + // Test empty range + loadedState.eventSink.invoke(RoomListEvents.UpdateVisibleRange(IntRange(1, 0))) + assertThat(roomSummaryDataSource.latestSlidingSyncRange).isNull() + // Update visible range and check that range is transmitted to the SDK after computation + loadedState.eventSink.invoke(RoomListEvents.UpdateVisibleRange(IntRange(0, 0))) + assertThat(roomSummaryDataSource.latestSlidingSyncRange).isEqualTo(IntRange(0, 20)) + loadedState.eventSink.invoke(RoomListEvents.UpdateVisibleRange(IntRange(0, 1))) + assertThat(roomSummaryDataSource.latestSlidingSyncRange).isEqualTo(IntRange(0, 21)) + loadedState.eventSink.invoke(RoomListEvents.UpdateVisibleRange(IntRange(19, 29))) + assertThat(roomSummaryDataSource.latestSlidingSyncRange).isEqualTo(IntRange(0, 49)) + loadedState.eventSink.invoke(RoomListEvents.UpdateVisibleRange(IntRange(49, 59))) + assertThat(roomSummaryDataSource.latestSlidingSyncRange).isEqualTo(IntRange(29, 79)) + loadedState.eventSink.invoke(RoomListEvents.UpdateVisibleRange(IntRange(149, 159))) + assertThat(roomSummaryDataSource.latestSlidingSyncRange).isEqualTo(IntRange(129, 179)) + loadedState.eventSink.invoke(RoomListEvents.UpdateVisibleRange(IntRange(149, 259))) + assertThat(roomSummaryDataSource.latestSlidingSyncRange).isEqualTo(IntRange(129, 279)) + } + } } private val aRoomListRoomSummary = RoomListRoomSummary( diff --git a/libraries/matrixtest/src/main/kotlin/io/element/android/libraries/matrixtest/room/InMemoryRoomSummaryDataSource.kt b/libraries/matrixtest/src/main/kotlin/io/element/android/libraries/matrixtest/room/InMemoryRoomSummaryDataSource.kt index 666f64acb4..17d7d108a2 100644 --- a/libraries/matrixtest/src/main/kotlin/io/element/android/libraries/matrixtest/room/InMemoryRoomSummaryDataSource.kt +++ b/libraries/matrixtest/src/main/kotlin/io/element/android/libraries/matrixtest/room/InMemoryRoomSummaryDataSource.kt @@ -33,5 +33,9 @@ class InMemoryRoomSummaryDataSource : RoomSummaryDataSource { return roomSummariesFlow } - override fun setSlidingSyncRange(range: IntRange) = Unit + var latestSlidingSyncRange: IntRange? = null + + override fun setSlidingSyncRange(range: IntRange) { + latestSlidingSyncRange = range + } } From 674a813f3b20662a3912ffb20f0c5a8b73145d07 Mon Sep 17 00:00:00 2001 From: Benoit Marty Date: Mon, 6 Feb 2023 18:09:18 +0100 Subject: [PATCH 08/51] Create module `dateformatter` --- features/roomlist/build.gradle.kts | 2 +- .../features/roomlist/RoomListPresenter.kt | 1 + .../roomlist/RoomListPresenterTests.kt | 11 ++--- libraries/dateformatter/.gitignore | 1 + libraries/dateformatter/build.gradle.kts | 40 +++++++++++++++++++ libraries/dateformatter/consumer-rules.pro | 0 libraries/dateformatter/proguard-rules.pro | 21 ++++++++++ .../src/main/AndroidManifest.xml | 16 ++++++++ .../dateformatter/LastMessageFormatter.kt | 21 ++++++++++ .../impl/DefaultLastMessageFormatter.kt | 14 +++++-- .../kotlin/extension/DependencyHandleScope.kt | 1 + settings.gradle.kts | 1 + 12 files changed, 119 insertions(+), 10 deletions(-) create mode 100644 libraries/dateformatter/.gitignore create mode 100644 libraries/dateformatter/build.gradle.kts create mode 100644 libraries/dateformatter/consumer-rules.pro create mode 100644 libraries/dateformatter/proguard-rules.pro create mode 100644 libraries/dateformatter/src/main/AndroidManifest.xml create mode 100644 libraries/dateformatter/src/main/kotlin/io/element/android/libraries/dateformatter/LastMessageFormatter.kt rename features/roomlist/src/main/kotlin/io/element/android/features/roomlist/LastMessageFormatter.kt => libraries/dateformatter/src/main/kotlin/io/element/android/libraries/dateformatter/impl/DefaultLastMessageFormatter.kt (87%) diff --git a/features/roomlist/build.gradle.kts b/features/roomlist/build.gradle.kts index de67bb010f..fe8112fb81 100644 --- a/features/roomlist/build.gradle.kts +++ b/features/roomlist/build.gradle.kts @@ -41,7 +41,7 @@ dependencies { implementation(projects.libraries.designsystem) implementation(projects.libraries.elementresources) implementation(projects.libraries.uiStrings) - implementation(libs.datetime) + implementation(projects.libraries.dateformatter) implementation(libs.accompanist.placeholder) testImplementation(libs.test.junit) diff --git a/features/roomlist/src/main/kotlin/io/element/android/features/roomlist/RoomListPresenter.kt b/features/roomlist/src/main/kotlin/io/element/android/features/roomlist/RoomListPresenter.kt index c684b0dd45..26d3bfd4ae 100644 --- a/features/roomlist/src/main/kotlin/io/element/android/features/roomlist/RoomListPresenter.kt +++ b/features/roomlist/src/main/kotlin/io/element/android/features/roomlist/RoomListPresenter.kt @@ -31,6 +31,7 @@ import io.element.android.features.roomlist.model.RoomListRoomSummaryPlaceholder import io.element.android.features.roomlist.model.RoomListState import io.element.android.libraries.architecture.Presenter import io.element.android.libraries.core.coroutine.parallelMap +import io.element.android.libraries.dateformatter.LastMessageFormatter import io.element.android.libraries.designsystem.components.avatar.AvatarData import io.element.android.libraries.designsystem.components.avatar.AvatarSize import io.element.android.libraries.matrix.MatrixClient diff --git a/features/roomlist/src/test/kotlin/io/element/android/features/roomlist/RoomListPresenterTests.kt b/features/roomlist/src/test/kotlin/io/element/android/features/roomlist/RoomListPresenterTests.kt index 7af95b0196..c1c2e8b141 100644 --- a/features/roomlist/src/test/kotlin/io/element/android/features/roomlist/RoomListPresenterTests.kt +++ b/features/roomlist/src/test/kotlin/io/element/android/features/roomlist/RoomListPresenterTests.kt @@ -24,6 +24,7 @@ import app.cash.turbine.test import com.google.common.truth.Truth.assertThat import io.element.android.features.roomlist.model.RoomListEvents import io.element.android.features.roomlist.model.RoomListRoomSummary +import io.element.android.libraries.dateformatter.impl.DefaultLastMessageFormatter import io.element.android.libraries.designsystem.components.avatar.AvatarData import io.element.android.libraries.matrix.core.SessionId import io.element.android.libraries.matrixtest.FakeMatrixClient @@ -45,7 +46,7 @@ class RoomListPresenterTests { FakeMatrixClient( SessionId("sessionId") ), - LastMessageFormatter() + DefaultLastMessageFormatter() ) moleculeFlow(RecompositionClock.Immediate) { presenter.present() @@ -63,7 +64,7 @@ class RoomListPresenterTests { FakeMatrixClient( SessionId("sessionId") ), - LastMessageFormatter() + DefaultLastMessageFormatter() ) moleculeFlow(RecompositionClock.Immediate) { presenter.present() @@ -85,7 +86,7 @@ class RoomListPresenterTests { sessionId = SessionId("sessionId"), roomSummaryDataSource = roomSummaryDataSource ), - LastMessageFormatter() + DefaultLastMessageFormatter() ) moleculeFlow(RecompositionClock.Immediate) { presenter.present() @@ -111,7 +112,7 @@ class RoomListPresenterTests { sessionId = SessionId("sessionId"), roomSummaryDataSource = roomSummaryDataSource ), - LastMessageFormatter() + DefaultLastMessageFormatter() ) moleculeFlow(RecompositionClock.Immediate) { presenter.present() @@ -142,7 +143,7 @@ class RoomListPresenterTests { sessionId = SessionId("sessionId"), roomSummaryDataSource = roomSummaryDataSource ), - LastMessageFormatter() + DefaultLastMessageFormatter() ) moleculeFlow(RecompositionClock.Immediate) { presenter.present() diff --git a/libraries/dateformatter/.gitignore b/libraries/dateformatter/.gitignore new file mode 100644 index 0000000000..42afabfd2a --- /dev/null +++ b/libraries/dateformatter/.gitignore @@ -0,0 +1 @@ +/build \ No newline at end of file diff --git a/libraries/dateformatter/build.gradle.kts b/libraries/dateformatter/build.gradle.kts new file mode 100644 index 0000000000..ef5b1fc980 --- /dev/null +++ b/libraries/dateformatter/build.gradle.kts @@ -0,0 +1,40 @@ +/* + * Copyright (c) 2022 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. + */ + +// TODO: Remove once https://youtrack.jetbrains.com/issue/KTIJ-19369 is fixed +@Suppress("DSL_SCOPE_VIOLATION") +plugins { + id("io.element.android-library") + alias(libs.plugins.ksp) + alias(libs.plugins.anvil) +} + +anvil { + generateDaggerFactories.set(true) +} + +android { + namespace = "io.element.android.libraries.dateformatter" + + dependencies { + anvil(projects.anvilcodegen) + implementation(libs.dagger) + implementation(projects.libraries.di) + implementation(projects.anvilannotations) + implementation(libs.datetime) + ksp(libs.showkase.processor) + } +} diff --git a/libraries/dateformatter/consumer-rules.pro b/libraries/dateformatter/consumer-rules.pro new file mode 100644 index 0000000000..e69de29bb2 diff --git a/libraries/dateformatter/proguard-rules.pro b/libraries/dateformatter/proguard-rules.pro new file mode 100644 index 0000000000..ff59496d81 --- /dev/null +++ b/libraries/dateformatter/proguard-rules.pro @@ -0,0 +1,21 @@ +# Add project specific ProGuard rules here. +# You can control the set of applied configuration files using the +# proguardFiles setting in build.gradle.kts. +# +# For more details, see +# http://developer.android.com/guide/developing/tools/proguard.html + +# If your project uses WebView with JS, uncomment the following +# and specify the fully qualified class name to the JavaScript interface +# class: +#-keepclassmembers class fqcn.of.javascript.interface.for.webview { +# public *; +#} + +# Uncomment this to preserve the line number information for +# debugging stack traces. +#-keepattributes SourceFile,LineNumberTable + +# If you keep the line number information, uncomment this to +# hide the original source file name. +#-renamesourcefileattribute SourceFile \ No newline at end of file diff --git a/libraries/dateformatter/src/main/AndroidManifest.xml b/libraries/dateformatter/src/main/AndroidManifest.xml new file mode 100644 index 0000000000..cf0e6386de --- /dev/null +++ b/libraries/dateformatter/src/main/AndroidManifest.xml @@ -0,0 +1,16 @@ + + diff --git a/libraries/dateformatter/src/main/kotlin/io/element/android/libraries/dateformatter/LastMessageFormatter.kt b/libraries/dateformatter/src/main/kotlin/io/element/android/libraries/dateformatter/LastMessageFormatter.kt new file mode 100644 index 0000000000..caa5886cf9 --- /dev/null +++ b/libraries/dateformatter/src/main/kotlin/io/element/android/libraries/dateformatter/LastMessageFormatter.kt @@ -0,0 +1,21 @@ +/* + * 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.dateformatter + +interface LastMessageFormatter { + fun format(timestamp: Long?): String +} diff --git a/features/roomlist/src/main/kotlin/io/element/android/features/roomlist/LastMessageFormatter.kt b/libraries/dateformatter/src/main/kotlin/io/element/android/libraries/dateformatter/impl/DefaultLastMessageFormatter.kt similarity index 87% rename from features/roomlist/src/main/kotlin/io/element/android/features/roomlist/LastMessageFormatter.kt rename to libraries/dateformatter/src/main/kotlin/io/element/android/libraries/dateformatter/impl/DefaultLastMessageFormatter.kt index 037ba5200d..13ffb9c7e0 100644 --- a/features/roomlist/src/main/kotlin/io/element/android/features/roomlist/LastMessageFormatter.kt +++ b/libraries/dateformatter/src/main/kotlin/io/element/android/libraries/dateformatter/impl/DefaultLastMessageFormatter.kt @@ -1,5 +1,5 @@ /* - * Copyright (c) 2022 New Vector Ltd + * 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. @@ -14,10 +14,13 @@ * limitations under the License. */ -package io.element.android.features.roomlist +package io.element.android.libraries.dateformatter.impl import android.text.format.DateFormat import android.text.format.DateUtils +import com.squareup.anvil.annotations.ContributesBinding +import io.element.android.libraries.dateformatter.LastMessageFormatter +import io.element.android.libraries.di.AppScope import kotlinx.datetime.Clock import kotlinx.datetime.Instant import kotlinx.datetime.LocalDateTime @@ -32,9 +35,12 @@ import java.util.Locale import javax.inject.Inject import kotlin.math.absoluteValue -class LastMessageFormatter @Inject constructor() { +@ContributesBinding(AppScope::class) +class DefaultLastMessageFormatter @Inject constructor() : LastMessageFormatter { + // TODO Inject in constructor private val clock: Clock = Clock.System + // TODO Inject in constructor private val locale: Locale = Locale.getDefault() private val onlyTimeFormatter: DateTimeFormatter by lazy { @@ -52,7 +58,7 @@ class LastMessageFormatter @Inject constructor() { DateTimeFormatter.ofPattern(pattern) } - fun format(timestamp: Long?): String { + override fun format(timestamp: Long?): String { if (timestamp == null) return "" val now: Instant = clock.now() val tsInstant = Instant.fromEpochMilliseconds(timestamp) diff --git a/plugins/src/main/kotlin/extension/DependencyHandleScope.kt b/plugins/src/main/kotlin/extension/DependencyHandleScope.kt index 710f603cad..8b63c38244 100644 --- a/plugins/src/main/kotlin/extension/DependencyHandleScope.kt +++ b/plugins/src/main/kotlin/extension/DependencyHandleScope.kt @@ -54,6 +54,7 @@ fun DependencyHandlerScope.allLibraries() { implementation(project(":libraries:matrixui")) implementation(project(":libraries:core")) implementation(project(":libraries:architecture")) + implementation(project(":libraries:dateformatter")) implementation(project(":libraries:di")) } diff --git a/settings.gradle.kts b/settings.gradle.kts index b85f5e6717..0c2cf97f2f 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -42,6 +42,7 @@ include(":libraries:rustsdk") include(":libraries:matrix") include(":libraries:matrixui") include(":libraries:textcomposer") +include(":libraries:dateformatter") include(":libraries:elementresources") include(":libraries:ui-strings") include(":libraries:testtags") From dfeb9d8c7f6cfac54080b9df8915c8c429de926f Mon Sep 17 00:00:00 2001 From: Benoit Marty Date: Mon, 6 Feb 2023 21:05:06 +0100 Subject: [PATCH 09/51] Create FakeLastMessageFormatter --- app/build.gradle.kts | 1 + .../roomlist/FakeLastMessageFormatter.kt | 30 ++++++++++++++++ .../roomlist/RoomListPresenterTests.kt | 22 ++++++++---- .../dateformatter/di/DateFormatterModule.kt | 34 +++++++++++++++++++ .../impl/DefaultLastMessageFormatter.kt | 11 +++--- 5 files changed, 84 insertions(+), 14 deletions(-) create mode 100644 features/roomlist/src/test/kotlin/io/element/android/features/roomlist/FakeLastMessageFormatter.kt create mode 100644 libraries/dateformatter/src/main/kotlin/io/element/android/libraries/dateformatter/di/DateFormatterModule.kt diff --git a/app/build.gradle.kts b/app/build.gradle.kts index f5a0799676..4aa95c2f7c 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -175,6 +175,7 @@ dependencies { implementation(libs.androidx.activity.compose) implementation(libs.androidx.startup) implementation(libs.coil) + implementation(libs.datetime) implementation(libs.dagger) kapt(libs.dagger.compiler) diff --git a/features/roomlist/src/test/kotlin/io/element/android/features/roomlist/FakeLastMessageFormatter.kt b/features/roomlist/src/test/kotlin/io/element/android/features/roomlist/FakeLastMessageFormatter.kt new file mode 100644 index 0000000000..997846056a --- /dev/null +++ b/features/roomlist/src/test/kotlin/io/element/android/features/roomlist/FakeLastMessageFormatter.kt @@ -0,0 +1,30 @@ +/* + * Copyright (c) 2023 New Vector Ltd + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package io.element.android.features.roomlist + +import io.element.android.libraries.dateformatter.LastMessageFormatter + +class FakeLastMessageFormatter : LastMessageFormatter { + private var format = "" + fun givenFormat(format: String) { + this.format = format + } + + override fun format(timestamp: Long?): String { + return format + } +} diff --git a/features/roomlist/src/test/kotlin/io/element/android/features/roomlist/RoomListPresenterTests.kt b/features/roomlist/src/test/kotlin/io/element/android/features/roomlist/RoomListPresenterTests.kt index c1c2e8b141..5fd2da920b 100644 --- a/features/roomlist/src/test/kotlin/io/element/android/features/roomlist/RoomListPresenterTests.kt +++ b/features/roomlist/src/test/kotlin/io/element/android/features/roomlist/RoomListPresenterTests.kt @@ -24,7 +24,7 @@ import app.cash.turbine.test import com.google.common.truth.Truth.assertThat import io.element.android.features.roomlist.model.RoomListEvents import io.element.android.features.roomlist.model.RoomListRoomSummary -import io.element.android.libraries.dateformatter.impl.DefaultLastMessageFormatter +import io.element.android.libraries.dateformatter.LastMessageFormatter import io.element.android.libraries.designsystem.components.avatar.AvatarData import io.element.android.libraries.matrix.core.SessionId import io.element.android.libraries.matrixtest.FakeMatrixClient @@ -46,7 +46,7 @@ class RoomListPresenterTests { FakeMatrixClient( SessionId("sessionId") ), - DefaultLastMessageFormatter() + createDateFormatter() ) moleculeFlow(RecompositionClock.Immediate) { presenter.present() @@ -64,7 +64,7 @@ class RoomListPresenterTests { FakeMatrixClient( SessionId("sessionId") ), - DefaultLastMessageFormatter() + createDateFormatter() ) moleculeFlow(RecompositionClock.Immediate) { presenter.present() @@ -86,7 +86,7 @@ class RoomListPresenterTests { sessionId = SessionId("sessionId"), roomSummaryDataSource = roomSummaryDataSource ), - DefaultLastMessageFormatter() + createDateFormatter() ) moleculeFlow(RecompositionClock.Immediate) { presenter.present() @@ -112,7 +112,7 @@ class RoomListPresenterTests { sessionId = SessionId("sessionId"), roomSummaryDataSource = roomSummaryDataSource ), - DefaultLastMessageFormatter() + createDateFormatter() ) moleculeFlow(RecompositionClock.Immediate) { presenter.present() @@ -143,7 +143,7 @@ class RoomListPresenterTests { sessionId = SessionId("sessionId"), roomSummaryDataSource = roomSummaryDataSource ), - DefaultLastMessageFormatter() + createDateFormatter() ) moleculeFlow(RecompositionClock.Immediate) { presenter.present() @@ -171,14 +171,22 @@ class RoomListPresenterTests { assertThat(roomSummaryDataSource.latestSlidingSyncRange).isEqualTo(IntRange(129, 279)) } } + + private fun createDateFormatter(): LastMessageFormatter { + return FakeLastMessageFormatter().apply { + givenFormat(A_FORMATTED_DATE) + } + } } +private const val A_FORMATTED_DATE = "formatted_date" + private val aRoomListRoomSummary = RoomListRoomSummary( id = A_ROOM_ID_VALUE, roomId = A_ROOM_ID, name = A_ROOM_NAME, hasUnread = true, - timestamp = "", + timestamp = A_FORMATTED_DATE, lastMessage = A_LAST_MESSAGE, avatarData = AvatarData(name = A_ROOM_NAME), isPlaceholder = false, diff --git a/libraries/dateformatter/src/main/kotlin/io/element/android/libraries/dateformatter/di/DateFormatterModule.kt b/libraries/dateformatter/src/main/kotlin/io/element/android/libraries/dateformatter/di/DateFormatterModule.kt new file mode 100644 index 0000000000..85deac5274 --- /dev/null +++ b/libraries/dateformatter/src/main/kotlin/io/element/android/libraries/dateformatter/di/DateFormatterModule.kt @@ -0,0 +1,34 @@ +/* + * 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.dateformatter.di + +import com.squareup.anvil.annotations.ContributesTo +import dagger.Module +import dagger.Provides +import io.element.android.libraries.di.AppScope +import kotlinx.datetime.Clock +import java.util.* + +@Module +@ContributesTo(AppScope::class) +object DateFormatterModule { + @Provides + fun providesClock(): Clock = Clock.System + + @Provides + fun providesLocale(): Locale = Locale.getDefault() +} diff --git a/libraries/dateformatter/src/main/kotlin/io/element/android/libraries/dateformatter/impl/DefaultLastMessageFormatter.kt b/libraries/dateformatter/src/main/kotlin/io/element/android/libraries/dateformatter/impl/DefaultLastMessageFormatter.kt index 13ffb9c7e0..28d76551ae 100644 --- a/libraries/dateformatter/src/main/kotlin/io/element/android/libraries/dateformatter/impl/DefaultLastMessageFormatter.kt +++ b/libraries/dateformatter/src/main/kotlin/io/element/android/libraries/dateformatter/impl/DefaultLastMessageFormatter.kt @@ -36,13 +36,10 @@ import javax.inject.Inject import kotlin.math.absoluteValue @ContributesBinding(AppScope::class) -class DefaultLastMessageFormatter @Inject constructor() : LastMessageFormatter { - - // TODO Inject in constructor - private val clock: Clock = Clock.System - // TODO Inject in constructor - private val locale: Locale = Locale.getDefault() - +class DefaultLastMessageFormatter @Inject constructor( + private val clock: Clock, + private val locale: Locale, +) : LastMessageFormatter { private val onlyTimeFormatter: DateTimeFormatter by lazy { val pattern = DateFormat.getBestDateTimePattern(locale, "HH:mm") DateTimeFormatter.ofPattern(pattern) From e706091a449e1003b22bb095f15a5bd251fe2300 Mon Sep 17 00:00:00 2001 From: Benoit Marty Date: Mon, 6 Feb 2023 21:52:28 +0100 Subject: [PATCH 10/51] Add test for `DefaultLastMessageFormatter` --- libraries/dateformatter/build.gradle.kts | 3 + .../impl/DefaultLastMessageFormatter.kt | 8 +- .../impl/DefaultLastMessageFormatterTest.kt | 105 ++++++++++++++++++ .../libraries/dateformatter/impl/FakeClock.kt | 30 +++++ 4 files changed, 142 insertions(+), 4 deletions(-) create mode 100644 libraries/dateformatter/src/test/kotlin/io/element/android/libraries/dateformatter/impl/DefaultLastMessageFormatterTest.kt create mode 100644 libraries/dateformatter/src/test/kotlin/io/element/android/libraries/dateformatter/impl/FakeClock.kt diff --git a/libraries/dateformatter/build.gradle.kts b/libraries/dateformatter/build.gradle.kts index ef5b1fc980..817435f8ac 100644 --- a/libraries/dateformatter/build.gradle.kts +++ b/libraries/dateformatter/build.gradle.kts @@ -36,5 +36,8 @@ android { implementation(projects.anvilannotations) implementation(libs.datetime) ksp(libs.showkase.processor) + + testImplementation(libs.test.junit) + testImplementation(libs.test.truth) } } diff --git a/libraries/dateformatter/src/main/kotlin/io/element/android/libraries/dateformatter/impl/DefaultLastMessageFormatter.kt b/libraries/dateformatter/src/main/kotlin/io/element/android/libraries/dateformatter/impl/DefaultLastMessageFormatter.kt index 28d76551ae..f3b9d03bef 100644 --- a/libraries/dateformatter/src/main/kotlin/io/element/android/libraries/dateformatter/impl/DefaultLastMessageFormatter.kt +++ b/libraries/dateformatter/src/main/kotlin/io/element/android/libraries/dateformatter/impl/DefaultLastMessageFormatter.kt @@ -41,17 +41,17 @@ class DefaultLastMessageFormatter @Inject constructor( private val locale: Locale, ) : LastMessageFormatter { private val onlyTimeFormatter: DateTimeFormatter by lazy { - val pattern = DateFormat.getBestDateTimePattern(locale, "HH:mm") + val pattern = DateFormat.getBestDateTimePattern(locale, "HH:mm") ?: "HH:mm" DateTimeFormatter.ofPattern(pattern) } private val dateWithMonthFormatter: DateTimeFormatter by lazy { - val pattern = DateFormat.getBestDateTimePattern(locale, "d MMM") + val pattern = DateFormat.getBestDateTimePattern(locale, "d MMM") ?: "d MMM" DateTimeFormatter.ofPattern(pattern) } private val dateWithYearFormatter: DateTimeFormatter by lazy { - val pattern = DateFormat.getBestDateTimePattern(locale, "dd.MM.yyyy") + val pattern = DateFormat.getBestDateTimePattern(locale, "dd.MM.yyyy") ?: "dd.MM.yyyy" DateTimeFormatter.ofPattern(pattern) } @@ -100,6 +100,6 @@ class DefaultLastMessageFormatter @Inject constructor( clock.now().toEpochMilliseconds(), DateUtils.DAY_IN_MILLIS, DateUtils.FORMAT_SHOW_WEEKDAY - ).toString() + )?.toString() ?: "" } } diff --git a/libraries/dateformatter/src/test/kotlin/io/element/android/libraries/dateformatter/impl/DefaultLastMessageFormatterTest.kt b/libraries/dateformatter/src/test/kotlin/io/element/android/libraries/dateformatter/impl/DefaultLastMessageFormatterTest.kt new file mode 100644 index 0000000000..fbf038a562 --- /dev/null +++ b/libraries/dateformatter/src/test/kotlin/io/element/android/libraries/dateformatter/impl/DefaultLastMessageFormatterTest.kt @@ -0,0 +1,105 @@ +/* + * 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.dateformatter.impl + +import com.google.common.truth.Truth.assertThat +import io.element.android.libraries.dateformatter.LastMessageFormatter +import kotlinx.datetime.Instant +import org.junit.Test +import java.util.Locale + +class DefaultLastMessageFormatterTest { + + @Test + fun `test null`() { + val now = "1980-04-06T18:35:24.00Z" + val formatter = createFormatter(now) + assertThat(formatter.format(null)).isEmpty() + } + + @Test + fun `test epoch`() { + val now = "1980-04-06T18:35:24.00Z" + val formatter = createFormatter(now) + assertThat(formatter.format(0)).isEqualTo("01.01.1970") + } + + @Test + fun `test now`() { + val now = "1980-04-06T18:35:24.00Z" + val dat = "1980-04-06T18:35:24.00Z" + val formatter = createFormatter(now) + assertThat(formatter.format(Instant.parse(dat).toEpochMilliseconds())).isEqualTo("20:35") + } + + @Test + fun `test one second before`() { + val now = "1980-04-06T18:35:24.00Z" + val dat = "1980-04-06T18:35:23.00Z" + val formatter = createFormatter(now) + assertThat(formatter.format(Instant.parse(dat).toEpochMilliseconds())).isEqualTo("20:35") + } + + @Test + fun `test one minute before`() { + val now = "1980-04-06T18:35:24.00Z" + val dat = "1980-04-06T18:34:24.00Z" + val formatter = createFormatter(now) + assertThat(formatter.format(Instant.parse(dat).toEpochMilliseconds())).isEqualTo("20:34") + } + + @Test + fun `test one hour before`() { + val now = "1980-04-06T18:35:24.00Z" + val dat = "1980-04-06T17:35:24.00Z" + val formatter = createFormatter(now) + assertThat(formatter.format(Instant.parse(dat).toEpochMilliseconds())).isEqualTo("19:35") + } + + @Test + fun `test one day before same time`() { + val now = "1980-04-06T18:35:24.00Z" + val dat = "1980-04-05T18:35:24.00Z" + val formatter = createFormatter(now) + // TODO DateUtils.getRelativeTimeSpanString returns null. + assertThat(formatter.format(Instant.parse(dat).toEpochMilliseconds())).isEqualTo("") + } + + @Test + fun `test one month before same time`() { + val now = "1980-04-06T18:35:24.00Z" + val dat = "1980-03-06T18:35:24.00Z" + val formatter = createFormatter(now) + assertThat(formatter.format(Instant.parse(dat).toEpochMilliseconds())).isEqualTo("6 Mar") + } + + @Test + fun `test one year before same time`() { + val now = "1980-04-06T18:35:24.00Z" + val dat = "1979-04-06T18:35:24.00Z" + val formatter = createFormatter(now) + assertThat(formatter.format(Instant.parse(dat).toEpochMilliseconds())).isEqualTo("06.04.1979") + } + + /** + * Create DefaultLastMessageFormatter and set current time to the provided date. + */ + private fun createFormatter(@Suppress("SameParameterValue") currentDate: String): LastMessageFormatter { + val clock = FakeClock().also { it.givenInstant(Instant.parse(currentDate)) } + return DefaultLastMessageFormatter(clock, Locale.US) + } +} diff --git a/libraries/dateformatter/src/test/kotlin/io/element/android/libraries/dateformatter/impl/FakeClock.kt b/libraries/dateformatter/src/test/kotlin/io/element/android/libraries/dateformatter/impl/FakeClock.kt new file mode 100644 index 0000000000..58a5495218 --- /dev/null +++ b/libraries/dateformatter/src/test/kotlin/io/element/android/libraries/dateformatter/impl/FakeClock.kt @@ -0,0 +1,30 @@ +/* + * Copyright (c) 2023 New Vector Ltd + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package io.element.android.libraries.dateformatter.impl + +import kotlinx.datetime.Clock +import kotlinx.datetime.Instant + +class FakeClock : Clock { + private var instant: Instant = Instant.fromEpochMilliseconds(0) + + fun givenInstant(instant: Instant) { + this.instant = instant + } + + override fun now(): Instant = instant +} From 3259956e3c25dfd6a5dda01bd5186303aac320f1 Mon Sep 17 00:00:00 2001 From: Benoit Marty Date: Tue, 7 Feb 2023 09:42:21 +0100 Subject: [PATCH 11/51] Running kover run the tests, no need to do it twice. --- .github/workflows/tests.yml | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index f4a12c9ec2..0ad1584754 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -21,10 +21,7 @@ jobs: cancel-in-progress: true steps: - uses: actions/checkout@v3 - - name: Run tests - run: ./gradlew test $CI_GRADLE_ARG_PROPERTIES - - name: Generate kover report - if: always() + - name: Run tests and generate kover report run: ./gradlew koverMergedReport $CI_GRADLE_ARG_PROPERTIES - name: Archive kover report From 1c890e223b6fc353e81ffb9afa442058dfb48cb3 Mon Sep 17 00:00:00 2001 From: Benoit Marty Date: Tue, 7 Feb 2023 09:47:56 +0100 Subject: [PATCH 12/51] Rename artifact --- .github/workflows/tests.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index 0ad1584754..19b3192e68 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -36,7 +36,7 @@ jobs: if: failure() uses: actions/upload-artifact@v3 with: - name: screenshot-results + name: tests-and-screenshot-tests-results path: | **/out/failures/ **/build/reports/tests/*UnitTest/ From 150c520501b646760350942b7e4356a817bb04d9 Mon Sep 17 00:00:00 2001 From: Benoit Marty Date: Tue, 7 Feb 2023 09:53:26 +0100 Subject: [PATCH 13/51] Fix test: also inject timezone to avoid relying on the system timezone. --- .../libraries/dateformatter/di/DateFormatterModule.kt | 4 ++++ .../dateformatter/impl/DefaultLastMessageFormatter.kt | 7 ++++--- .../impl/DefaultLastMessageFormatterTest.kt | 11 ++++++----- 3 files changed, 14 insertions(+), 8 deletions(-) diff --git a/libraries/dateformatter/src/main/kotlin/io/element/android/libraries/dateformatter/di/DateFormatterModule.kt b/libraries/dateformatter/src/main/kotlin/io/element/android/libraries/dateformatter/di/DateFormatterModule.kt index 85deac5274..feab851a8b 100644 --- a/libraries/dateformatter/src/main/kotlin/io/element/android/libraries/dateformatter/di/DateFormatterModule.kt +++ b/libraries/dateformatter/src/main/kotlin/io/element/android/libraries/dateformatter/di/DateFormatterModule.kt @@ -21,6 +21,7 @@ import dagger.Module import dagger.Provides import io.element.android.libraries.di.AppScope import kotlinx.datetime.Clock +import kotlinx.datetime.TimeZone import java.util.* @Module @@ -31,4 +32,7 @@ object DateFormatterModule { @Provides fun providesLocale(): Locale = Locale.getDefault() + + @Provides + fun providesTimezone(): TimeZone = TimeZone.currentSystemDefault() } diff --git a/libraries/dateformatter/src/main/kotlin/io/element/android/libraries/dateformatter/impl/DefaultLastMessageFormatter.kt b/libraries/dateformatter/src/main/kotlin/io/element/android/libraries/dateformatter/impl/DefaultLastMessageFormatter.kt index f3b9d03bef..a466491766 100644 --- a/libraries/dateformatter/src/main/kotlin/io/element/android/libraries/dateformatter/impl/DefaultLastMessageFormatter.kt +++ b/libraries/dateformatter/src/main/kotlin/io/element/android/libraries/dateformatter/impl/DefaultLastMessageFormatter.kt @@ -39,6 +39,7 @@ import kotlin.math.absoluteValue class DefaultLastMessageFormatter @Inject constructor( private val clock: Clock, private val locale: Locale, + private val timezone: TimeZone, ) : LastMessageFormatter { private val onlyTimeFormatter: DateTimeFormatter by lazy { val pattern = DateFormat.getBestDateTimePattern(locale, "HH:mm") ?: "HH:mm" @@ -59,8 +60,8 @@ class DefaultLastMessageFormatter @Inject constructor( if (timestamp == null) return "" val now: Instant = clock.now() val tsInstant = Instant.fromEpochMilliseconds(timestamp) - val nowDateTime = now.toLocalDateTime(TimeZone.currentSystemDefault()) - val tsDateTime = tsInstant.toLocalDateTime(TimeZone.currentSystemDefault()) + val nowDateTime = now.toLocalDateTime(timezone) + val tsDateTime = tsInstant.toLocalDateTime(timezone) val isSameDay = nowDateTime.date == tsDateTime.date return when { isSameDay -> { @@ -80,7 +81,7 @@ class DefaultLastMessageFormatter @Inject constructor( return if (period.years.absoluteValue >= 1) { formatDateWithYear(date) } else if (period.days.absoluteValue < 2 && period.months.absoluteValue < 1) { - getRelativeDay(date.toInstant(TimeZone.currentSystemDefault()).toEpochMilliseconds()) + getRelativeDay(date.toInstant(timezone).toEpochMilliseconds()) } else { formatDateWithMonth(date) } diff --git a/libraries/dateformatter/src/test/kotlin/io/element/android/libraries/dateformatter/impl/DefaultLastMessageFormatterTest.kt b/libraries/dateformatter/src/test/kotlin/io/element/android/libraries/dateformatter/impl/DefaultLastMessageFormatterTest.kt index fbf038a562..c21dcf4230 100644 --- a/libraries/dateformatter/src/test/kotlin/io/element/android/libraries/dateformatter/impl/DefaultLastMessageFormatterTest.kt +++ b/libraries/dateformatter/src/test/kotlin/io/element/android/libraries/dateformatter/impl/DefaultLastMessageFormatterTest.kt @@ -19,6 +19,7 @@ package io.element.android.libraries.dateformatter.impl import com.google.common.truth.Truth.assertThat import io.element.android.libraries.dateformatter.LastMessageFormatter import kotlinx.datetime.Instant +import kotlinx.datetime.TimeZone import org.junit.Test import java.util.Locale @@ -43,7 +44,7 @@ class DefaultLastMessageFormatterTest { val now = "1980-04-06T18:35:24.00Z" val dat = "1980-04-06T18:35:24.00Z" val formatter = createFormatter(now) - assertThat(formatter.format(Instant.parse(dat).toEpochMilliseconds())).isEqualTo("20:35") + assertThat(formatter.format(Instant.parse(dat).toEpochMilliseconds())).isEqualTo("18:35") } @Test @@ -51,7 +52,7 @@ class DefaultLastMessageFormatterTest { val now = "1980-04-06T18:35:24.00Z" val dat = "1980-04-06T18:35:23.00Z" val formatter = createFormatter(now) - assertThat(formatter.format(Instant.parse(dat).toEpochMilliseconds())).isEqualTo("20:35") + assertThat(formatter.format(Instant.parse(dat).toEpochMilliseconds())).isEqualTo("18:35") } @Test @@ -59,7 +60,7 @@ class DefaultLastMessageFormatterTest { val now = "1980-04-06T18:35:24.00Z" val dat = "1980-04-06T18:34:24.00Z" val formatter = createFormatter(now) - assertThat(formatter.format(Instant.parse(dat).toEpochMilliseconds())).isEqualTo("20:34") + assertThat(formatter.format(Instant.parse(dat).toEpochMilliseconds())).isEqualTo("18:34") } @Test @@ -67,7 +68,7 @@ class DefaultLastMessageFormatterTest { val now = "1980-04-06T18:35:24.00Z" val dat = "1980-04-06T17:35:24.00Z" val formatter = createFormatter(now) - assertThat(formatter.format(Instant.parse(dat).toEpochMilliseconds())).isEqualTo("19:35") + assertThat(formatter.format(Instant.parse(dat).toEpochMilliseconds())).isEqualTo("17:35") } @Test @@ -100,6 +101,6 @@ class DefaultLastMessageFormatterTest { */ private fun createFormatter(@Suppress("SameParameterValue") currentDate: String): LastMessageFormatter { val clock = FakeClock().also { it.givenInstant(Instant.parse(currentDate)) } - return DefaultLastMessageFormatter(clock, Locale.US) + return DefaultLastMessageFormatter(clock, Locale.US, TimeZone.UTC) } } From 8b599a6549557cb2666dd4762a48ed410340cb6e Mon Sep 17 00:00:00 2001 From: Benoit Marty Date: Tue, 7 Feb 2023 10:30:05 +0100 Subject: [PATCH 14/51] Rename class. --- .../android/features/roomlist/RoomListPresenterTests.kt | 8 ++++---- .../android/libraries/matrixtest/FakeMatrixClient.kt | 4 ++-- ...mSummaryDataSource.kt => FakeRoomSummaryDataSource.kt} | 3 ++- 3 files changed, 8 insertions(+), 7 deletions(-) rename libraries/matrixtest/src/main/kotlin/io/element/android/libraries/matrixtest/room/{InMemoryRoomSummaryDataSource.kt => FakeRoomSummaryDataSource.kt} (94%) diff --git a/features/roomlist/src/test/kotlin/io/element/android/features/roomlist/RoomListPresenterTests.kt b/features/roomlist/src/test/kotlin/io/element/android/features/roomlist/RoomListPresenterTests.kt index 5fd2da920b..baa547b4bb 100644 --- a/features/roomlist/src/test/kotlin/io/element/android/features/roomlist/RoomListPresenterTests.kt +++ b/features/roomlist/src/test/kotlin/io/element/android/features/roomlist/RoomListPresenterTests.kt @@ -32,7 +32,7 @@ import io.element.android.libraries.matrixtest.core.A_ROOM_ID import io.element.android.libraries.matrixtest.core.A_ROOM_ID_VALUE import io.element.android.libraries.matrixtest.room.A_LAST_MESSAGE import io.element.android.libraries.matrixtest.room.A_ROOM_NAME -import io.element.android.libraries.matrixtest.room.InMemoryRoomSummaryDataSource +import io.element.android.libraries.matrixtest.room.FakeRoomSummaryDataSource import io.element.android.libraries.matrixtest.room.aRoomSummaryFilled import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.test.runTest @@ -80,7 +80,7 @@ class RoomListPresenterTests { @Test fun `present - load 1 room with success`() = runTest { - val roomSummaryDataSource = InMemoryRoomSummaryDataSource() + val roomSummaryDataSource = FakeRoomSummaryDataSource() val presenter = RoomListPresenter( FakeMatrixClient( sessionId = SessionId("sessionId"), @@ -106,7 +106,7 @@ class RoomListPresenterTests { @Test fun `present - load 1 room with success and filter rooms`() = runTest { - val roomSummaryDataSource = InMemoryRoomSummaryDataSource() + val roomSummaryDataSource = FakeRoomSummaryDataSource() val presenter = RoomListPresenter( FakeMatrixClient( sessionId = SessionId("sessionId"), @@ -137,7 +137,7 @@ class RoomListPresenterTests { @Test fun `present - update visible range`() = runTest { - val roomSummaryDataSource = InMemoryRoomSummaryDataSource() + val roomSummaryDataSource = FakeRoomSummaryDataSource() val presenter = RoomListPresenter( FakeMatrixClient( sessionId = SessionId("sessionId"), diff --git a/libraries/matrixtest/src/main/kotlin/io/element/android/libraries/matrixtest/FakeMatrixClient.kt b/libraries/matrixtest/src/main/kotlin/io/element/android/libraries/matrixtest/FakeMatrixClient.kt index dd0181f503..fb0490cd64 100644 --- a/libraries/matrixtest/src/main/kotlin/io/element/android/libraries/matrixtest/FakeMatrixClient.kt +++ b/libraries/matrixtest/src/main/kotlin/io/element/android/libraries/matrixtest/FakeMatrixClient.kt @@ -25,12 +25,12 @@ import io.element.android.libraries.matrix.room.MatrixRoom import io.element.android.libraries.matrix.room.RoomSummaryDataSource import io.element.android.libraries.matrixtest.media.FakeMediaResolver import io.element.android.libraries.matrixtest.room.FakeMatrixRoom -import io.element.android.libraries.matrixtest.room.InMemoryRoomSummaryDataSource +import io.element.android.libraries.matrixtest.room.FakeRoomSummaryDataSource import org.matrix.rustcomponents.sdk.MediaSource class FakeMatrixClient( override val sessionId: SessionId, - val roomSummaryDataSource: RoomSummaryDataSource = InMemoryRoomSummaryDataSource() + val roomSummaryDataSource: RoomSummaryDataSource = FakeRoomSummaryDataSource() ) : MatrixClient { override fun getRoom(roomId: RoomId): MatrixRoom? { diff --git a/libraries/matrixtest/src/main/kotlin/io/element/android/libraries/matrixtest/room/InMemoryRoomSummaryDataSource.kt b/libraries/matrixtest/src/main/kotlin/io/element/android/libraries/matrixtest/room/FakeRoomSummaryDataSource.kt similarity index 94% rename from libraries/matrixtest/src/main/kotlin/io/element/android/libraries/matrixtest/room/InMemoryRoomSummaryDataSource.kt rename to libraries/matrixtest/src/main/kotlin/io/element/android/libraries/matrixtest/room/FakeRoomSummaryDataSource.kt index 17d7d108a2..9d7cb3e377 100644 --- a/libraries/matrixtest/src/main/kotlin/io/element/android/libraries/matrixtest/room/InMemoryRoomSummaryDataSource.kt +++ b/libraries/matrixtest/src/main/kotlin/io/element/android/libraries/matrixtest/room/FakeRoomSummaryDataSource.kt @@ -21,7 +21,7 @@ import io.element.android.libraries.matrix.room.RoomSummaryDataSource import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow -class InMemoryRoomSummaryDataSource : RoomSummaryDataSource { +class FakeRoomSummaryDataSource : RoomSummaryDataSource { private val roomSummariesFlow = MutableStateFlow>(emptyList()) @@ -34,6 +34,7 @@ class InMemoryRoomSummaryDataSource : RoomSummaryDataSource { } var latestSlidingSyncRange: IntRange? = null + private set override fun setSlidingSyncRange(range: IntRange) { latestSlidingSyncRange = range From 312cc4ce2254bc7126429f72c5186581a3e3bcb7 Mon Sep 17 00:00:00 2001 From: Benoit Marty Date: Tue, 7 Feb 2023 15:57:18 +0100 Subject: [PATCH 15/51] Kover: add verify rules: global and for Presenters --- .github/workflows/quality.yml | 9 +++++++++ build.gradle.kts | 31 +++++++++++++++++++++++++++++++ 2 files changed, 40 insertions(+) diff --git a/.github/workflows/quality.yml b/.github/workflows/quality.yml index 78c407cde1..31630acf97 100644 --- a/.github/workflows/quality.yml +++ b/.github/workflows/quality.yml @@ -29,6 +29,15 @@ jobs: name: linting-report path: | */build/reports/**/*.* + - name: Check Kover rules + run: ./gradlew koverMergedVerify $CI_GRADLE_ARG_PROPERTIES + - name: Upload reports + if: failure() + uses: actions/upload-artifact@v3 + with: + name: kover-report + path: | + */build/reports/kover/merged/verification/errors.txt - name: Prepare Danger if: always() run: | diff --git a/build.gradle.kts b/build.gradle.kts index f2f16714ea..97e26f956b 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -174,4 +174,35 @@ koverMerged { ) } } + + // Run ./gradlew koverMergedVerify to check the rules. + verify { + // Does not seems to work, so also run the task manually on the workflow. + onCheck.set(true) + // General rule: minimum code coverage. + rule { + name = "Global minimum code coverage." + target = kotlinx.kover.api.VerificationTarget.ALL + bound { + minValue = 35 + // Setting a max value, so that if coverage is bigger, it means that we have to change minValue. + maxValue = 40 + counter = kotlinx.kover.api.CounterType.LINE + valueType = kotlinx.kover.api.VerificationValueType.COVERED_PERCENTAGE + } + } + // Rule to ensure that coverage of Presenters is sufficient. + rule { + name = "Check code coverage of presenters" + target = kotlinx.kover.api.VerificationTarget.CLASS + overrideClassFilter { + includes += "*Presenter" + } + bound { + minValue = 80 + counter = kotlinx.kover.api.CounterType.LINE + valueType = kotlinx.kover.api.VerificationValueType.COVERED_PERCENTAGE + } + } + } } From cde9e2063812ceccfb64206848857cb1e61c4307 Mon Sep 17 00:00:00 2001 From: Benoit Marty Date: Tue, 7 Feb 2023 16:32:24 +0100 Subject: [PATCH 16/51] Add link to the plugin documentation. --- build.gradle.kts | 1 + 1 file changed, 1 insertion(+) diff --git a/build.gradle.kts b/build.gradle.kts index 97e26f956b..6ee2173c49 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -155,6 +155,7 @@ allprojects { apply(plugin = "kover") } +// https://kotlin.github.io/kotlinx-kover/ // Run `./gradlew koverMergedHtmlReport` to get report at ./build/reports/kover // Run `./gradlew koverMergedReport` to also get XML report koverMerged { From 0b6d7b0bc5f849155c995226315ab7b4516fb02b Mon Sep 17 00:00:00 2001 From: Benoit Marty Date: Tue, 7 Feb 2023 16:57:33 +0100 Subject: [PATCH 17/51] Add test for `ChangeServerPresenter` --- features/login/build.gradle.kts | 7 ++ .../changeserver/ChangeServerPresenterTest.kt | 81 +++++++++++++++++++ .../android/libraries/architecture/Async.kt | 3 +- .../auth/FakeAuthenticationService.kt | 56 +++++++++++++ 4 files changed, 146 insertions(+), 1 deletion(-) create mode 100644 features/login/src/test/kotlin/io/element/android/features/login/changeserver/ChangeServerPresenterTest.kt create mode 100644 libraries/matrixtest/src/main/kotlin/io/element/android/libraries/matrixtest/auth/FakeAuthenticationService.kt diff --git a/features/login/build.gradle.kts b/features/login/build.gradle.kts index c181d3b75e..f4f8ca4844 100644 --- a/features/login/build.gradle.kts +++ b/features/login/build.gradle.kts @@ -42,6 +42,13 @@ dependencies { implementation(projects.libraries.testtags) implementation(projects.libraries.uiStrings) ksp(libs.showkase.processor) + testImplementation(libs.test.junit) + testImplementation(libs.coroutines.test) + testImplementation(libs.molecule.runtime) + testImplementation(libs.test.truth) + testImplementation(libs.test.turbine) + testImplementation(projects.libraries.matrixtest) + androidTestImplementation(libs.test.junitext) } diff --git a/features/login/src/test/kotlin/io/element/android/features/login/changeserver/ChangeServerPresenterTest.kt b/features/login/src/test/kotlin/io/element/android/features/login/changeserver/ChangeServerPresenterTest.kt new file mode 100644 index 0000000000..c8ff3de1ae --- /dev/null +++ b/features/login/src/test/kotlin/io/element/android/features/login/changeserver/ChangeServerPresenterTest.kt @@ -0,0 +1,81 @@ +/* + * 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. + */ + +@file:OptIn(ExperimentalCoroutinesApi::class) + +package io.element.android.features.login.changeserver + +import app.cash.molecule.RecompositionClock +import app.cash.molecule.moleculeFlow +import app.cash.turbine.test +import com.google.common.truth.Truth.assertThat +import io.element.android.libraries.architecture.Async +import io.element.android.libraries.matrixtest.auth.A_HOMESERVER +import io.element.android.libraries.matrixtest.auth.FakeAuthenticationService +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.test.runTest +import org.junit.Test + +class ChangeServerPresenterTest { + @Test + fun `present - should start with default homeserver`() = runTest { + val presenter = ChangeServerPresenter( + FakeAuthenticationService(), + ) + moleculeFlow(RecompositionClock.Immediate) { + presenter.present() + }.test { + val initialState = awaitItem() + assertThat(initialState.homeserver).isEqualTo(A_HOMESERVER) + assertThat(initialState.submitEnabled).isTrue() + } + } + + @Test + fun `present - disable if empty or not correct`() = runTest { + val presenter = ChangeServerPresenter( + FakeAuthenticationService(), + ) + moleculeFlow(RecompositionClock.Immediate) { + presenter.present() + }.test { + val initialState = awaitItem() + initialState.eventSink.invoke(ChangeServerEvents.SetServer("")) + val emptyState = awaitItem() + assertThat(emptyState.homeserver).isEqualTo("") + assertThat(emptyState.submitEnabled).isFalse() + } + } + + @Test + fun `present - submit`() = runTest { + val presenter = ChangeServerPresenter( + FakeAuthenticationService(), + ) + moleculeFlow(RecompositionClock.Immediate) { + presenter.present() + }.test { + val initialState = awaitItem() + initialState.eventSink.invoke(ChangeServerEvents.Submit) + val loadingState = awaitItem() + assertThat(loadingState.submitEnabled).isFalse() + assertThat(loadingState.changeServerAction).isInstanceOf(Async.Loading::class.java) + val successState = awaitItem() + assertThat(successState.submitEnabled).isTrue() + assertThat(successState.changeServerAction).isInstanceOf(Async.Success::class.java) + } + } +} diff --git a/libraries/architecture/src/main/kotlin/io/element/android/libraries/architecture/Async.kt b/libraries/architecture/src/main/kotlin/io/element/android/libraries/architecture/Async.kt index 94d81a28e2..d3ed18bee2 100644 --- a/libraries/architecture/src/main/kotlin/io/element/android/libraries/architecture/Async.kt +++ b/libraries/architecture/src/main/kotlin/io/element/android/libraries/architecture/Async.kt @@ -39,7 +39,8 @@ sealed interface Async { suspend fun (suspend () -> T).execute(state: MutableState>) { try { state.value = Async.Loading() - state.value = Async.Success(this()) + val result = this() + state.value = Async.Success(result) } catch (error: Throwable) { state.value = Async.Failure(error) } diff --git a/libraries/matrixtest/src/main/kotlin/io/element/android/libraries/matrixtest/auth/FakeAuthenticationService.kt b/libraries/matrixtest/src/main/kotlin/io/element/android/libraries/matrixtest/auth/FakeAuthenticationService.kt new file mode 100644 index 0000000000..e637ebb722 --- /dev/null +++ b/libraries/matrixtest/src/main/kotlin/io/element/android/libraries/matrixtest/auth/FakeAuthenticationService.kt @@ -0,0 +1,56 @@ +/* + * 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.matrixtest.auth + +import io.element.android.libraries.matrix.MatrixClient +import io.element.android.libraries.matrix.auth.MatrixAuthenticationService +import io.element.android.libraries.matrix.core.SessionId +import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.flowOf + +const val A_HOMESERVER = "matrix.org" + +class FakeAuthenticationService : MatrixAuthenticationService { + override fun isLoggedIn(): Flow { + return flowOf(false) + } + + override suspend fun getLatestSessionId(): SessionId? { + return null + } + + override suspend fun restoreSession(sessionId: SessionId): MatrixClient? { + return null + } + + override fun getHomeserver(): String? { + return null + } + + override fun getHomeserverOrDefault(): String { + return A_HOMESERVER + } + + override suspend fun setHomeserver(homeserver: String) { + delay(100) + } + + override suspend fun login(username: String, password: String): SessionId { + return SessionId("test") + } +} From 7d161730c069ddf036373277aa44070edcab617c Mon Sep 17 00:00:00 2001 From: Benoit Marty Date: Tue, 7 Feb 2023 17:34:22 +0100 Subject: [PATCH 18/51] Add test for `LoginRootPresenter` --- .../login/root/LoginRootPresenterTest.kt | 135 ++++++++++++++++++ .../auth/FakeAuthenticationService.kt | 22 ++- 2 files changed, 155 insertions(+), 2 deletions(-) create mode 100644 features/login/src/test/kotlin/io/element/android/features/login/root/LoginRootPresenterTest.kt diff --git a/features/login/src/test/kotlin/io/element/android/features/login/root/LoginRootPresenterTest.kt b/features/login/src/test/kotlin/io/element/android/features/login/root/LoginRootPresenterTest.kt new file mode 100644 index 0000000000..ba3b5e644a --- /dev/null +++ b/features/login/src/test/kotlin/io/element/android/features/login/root/LoginRootPresenterTest.kt @@ -0,0 +1,135 @@ +/* + * 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. + */ + +@file:OptIn(ExperimentalCoroutinesApi::class) + +package io.element.android.features.login.root + +import app.cash.molecule.RecompositionClock +import app.cash.molecule.moleculeFlow +import app.cash.turbine.test +import com.google.common.truth.Truth.assertThat +import io.element.android.libraries.matrix.core.SessionId +import io.element.android.libraries.matrixtest.auth.A_FAILURE +import io.element.android.libraries.matrixtest.auth.A_HOMESERVER +import io.element.android.libraries.matrixtest.auth.A_HOMESERVER_2 +import io.element.android.libraries.matrixtest.auth.A_LOGIN +import io.element.android.libraries.matrixtest.auth.A_PASSWORD +import io.element.android.libraries.matrixtest.auth.A_SESSION_ID +import io.element.android.libraries.matrixtest.auth.FakeAuthenticationService +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.test.runTest +import org.junit.Test + +class LoginRootPresenterTest { + @Test + fun `present - initial state`() = runTest { + val presenter = LoginRootPresenter( + FakeAuthenticationService(), + ) + moleculeFlow(RecompositionClock.Immediate) { + presenter.present() + }.test { + val initialState = awaitItem() + assertThat(initialState.homeserver).isEqualTo(A_HOMESERVER) + assertThat(initialState.loggedInState).isEqualTo(LoggedInState.NotLoggedIn) + assertThat(initialState.formState).isEqualTo(LoginFormState.Default) + assertThat(initialState.submitEnabled).isFalse() + } + } + + @Test + fun `present - enter login and password`() = runTest { + val presenter = LoginRootPresenter( + FakeAuthenticationService(), + ) + moleculeFlow(RecompositionClock.Immediate) { + presenter.present() + }.test { + val initialState = awaitItem() + initialState.eventSink.invoke(LoginRootEvents.SetLogin(A_LOGIN)) + val loginState = awaitItem() + assertThat(loginState.formState).isEqualTo(LoginFormState(login = A_LOGIN, password = "")) + assertThat(loginState.submitEnabled).isFalse() + initialState.eventSink.invoke(LoginRootEvents.SetPassword(A_PASSWORD)) + val loginAndPasswordState = awaitItem() + assertThat(loginAndPasswordState.formState).isEqualTo(LoginFormState(login = A_LOGIN, password = A_PASSWORD)) + assertThat(loginAndPasswordState.submitEnabled).isTrue() + } + } + + @Test + fun `present - submit`() = runTest { + val presenter = LoginRootPresenter( + FakeAuthenticationService(), + ) + moleculeFlow(RecompositionClock.Immediate) { + presenter.present() + }.test { + val initialState = awaitItem() + initialState.eventSink.invoke(LoginRootEvents.SetLogin(A_LOGIN)) + initialState.eventSink.invoke(LoginRootEvents.SetPassword(A_PASSWORD)) + skipItems(1) + val loginAndPasswordState = awaitItem() + loginAndPasswordState.eventSink.invoke(LoginRootEvents.Submit) + val submitState = awaitItem() + assertThat(submitState.loggedInState).isEqualTo(LoggedInState.LoggingIn) + val loggedInState = awaitItem() + assertThat(loggedInState.loggedInState).isEqualTo(LoggedInState.LoggedIn(SessionId(A_SESSION_ID))) + } + } + + @Test + fun `present - submit with error`() = runTest { + val authenticationService = FakeAuthenticationService() + val presenter = LoginRootPresenter( + authenticationService, + ) + moleculeFlow(RecompositionClock.Immediate) { + presenter.present() + }.test { + val initialState = awaitItem() + initialState.eventSink.invoke(LoginRootEvents.SetLogin(A_LOGIN)) + initialState.eventSink.invoke(LoginRootEvents.SetPassword(A_PASSWORD)) + skipItems(1) + val loginAndPasswordState = awaitItem() + authenticationService.givenLoginError(A_FAILURE) + loginAndPasswordState.eventSink.invoke(LoginRootEvents.Submit) + val submitState = awaitItem() + assertThat(submitState.loggedInState).isEqualTo(LoggedInState.LoggingIn) + val loggedInState = awaitItem() + assertThat(loggedInState.loggedInState).isEqualTo(LoggedInState.ErrorLoggingIn(A_FAILURE)) + } + } + + @Test + fun `present - refresh server`() = runTest { + val authenticationService = FakeAuthenticationService() + val presenter = LoginRootPresenter( + authenticationService, + ) + moleculeFlow(RecompositionClock.Immediate) { + presenter.present() + }.test { + val initialState = awaitItem() + assertThat(initialState.homeserver).isEqualTo(A_HOMESERVER) + authenticationService.givenHomeserver(A_HOMESERVER_2) + initialState.eventSink.invoke(LoginRootEvents.RefreshHomeServer) + val refreshedState = awaitItem() + assertThat(refreshedState.homeserver).isEqualTo(A_HOMESERVER_2) + } + } +} diff --git a/libraries/matrixtest/src/main/kotlin/io/element/android/libraries/matrixtest/auth/FakeAuthenticationService.kt b/libraries/matrixtest/src/main/kotlin/io/element/android/libraries/matrixtest/auth/FakeAuthenticationService.kt index e637ebb722..936bd01545 100644 --- a/libraries/matrixtest/src/main/kotlin/io/element/android/libraries/matrixtest/auth/FakeAuthenticationService.kt +++ b/libraries/matrixtest/src/main/kotlin/io/element/android/libraries/matrixtest/auth/FakeAuthenticationService.kt @@ -24,8 +24,16 @@ import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.flowOf const val A_HOMESERVER = "matrix.org" +const val A_HOMESERVER_2 = "matrix-client.org" +const val A_SESSION_ID = "sessionId" +const val A_LOGIN = "login" +const val A_PASSWORD = "password" +val A_FAILURE = Throwable("error") class FakeAuthenticationService : MatrixAuthenticationService { + private var homeserver: String = A_HOMESERVER + private var loginError: Throwable? = null + override fun isLoggedIn(): Flow { return flowOf(false) } @@ -42,8 +50,12 @@ class FakeAuthenticationService : MatrixAuthenticationService { return null } + fun givenHomeserver(homeserver: String) { + this.homeserver = homeserver + } + override fun getHomeserverOrDefault(): String { - return A_HOMESERVER + return homeserver } override suspend fun setHomeserver(homeserver: String) { @@ -51,6 +63,12 @@ class FakeAuthenticationService : MatrixAuthenticationService { } override suspend fun login(username: String, password: String): SessionId { - return SessionId("test") + delay(100) + loginError?.let { throw it } + return SessionId(A_SESSION_ID) + } + + fun givenLoginError(throwable: Throwable?) { + loginError = throwable } } From 97a9dc1dff42a05ea408ab190c93d7f15efb6d70 Mon Sep 17 00:00:00 2001 From: Benoit Marty Date: Tue, 7 Feb 2023 17:47:38 +0100 Subject: [PATCH 19/51] Add test for `LogoutPreferencePresenter` --- features/logout/build.gradle.kts | 7 ++ .../logout/LogoutPreferencePresenterTest.kt | 83 +++++++++++++++++++ .../libraries/matrixtest/FakeMatrixClient.kt | 12 ++- 3 files changed, 101 insertions(+), 1 deletion(-) create mode 100644 features/logout/src/test/kotlin/io/element/android/features/logout/LogoutPreferencePresenterTest.kt diff --git a/features/logout/build.gradle.kts b/features/logout/build.gradle.kts index e2df3becf4..2935965b80 100644 --- a/features/logout/build.gradle.kts +++ b/features/logout/build.gradle.kts @@ -40,6 +40,13 @@ dependencies { implementation(projects.libraries.elementresources) implementation(projects.libraries.uiStrings) ksp(libs.showkase.processor) + testImplementation(libs.test.junit) + testImplementation(libs.coroutines.test) + testImplementation(libs.molecule.runtime) + testImplementation(libs.test.truth) + testImplementation(libs.test.turbine) + testImplementation(projects.libraries.matrixtest) + androidTestImplementation(libs.test.junitext) } diff --git a/features/logout/src/test/kotlin/io/element/android/features/logout/LogoutPreferencePresenterTest.kt b/features/logout/src/test/kotlin/io/element/android/features/logout/LogoutPreferencePresenterTest.kt new file mode 100644 index 0000000000..1f1643d981 --- /dev/null +++ b/features/logout/src/test/kotlin/io/element/android/features/logout/LogoutPreferencePresenterTest.kt @@ -0,0 +1,83 @@ +/* + * 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. + */ + +@file:OptIn(ExperimentalCoroutinesApi::class) + +package io.element.android.features.logout + +import app.cash.molecule.RecompositionClock +import app.cash.molecule.moleculeFlow +import app.cash.turbine.test +import com.google.common.truth.Truth.assertThat +import io.element.android.libraries.architecture.Async +import io.element.android.libraries.matrix.core.SessionId +import io.element.android.libraries.matrixtest.FakeMatrixClient +import io.element.android.libraries.matrixtest.auth.A_FAILURE +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.test.runTest +import org.junit.Test + +class LogoutPreferencePresenterTest { + @Test + fun `present - initial state`() = runTest { + val presenter = LogoutPreferencePresenter( + FakeMatrixClient(SessionId("sessionId")), + ) + moleculeFlow(RecompositionClock.Immediate) { + presenter.present() + }.test { + val initialState = awaitItem() + assertThat(initialState.logoutAction).isEqualTo(Async.Uninitialized) + } + } + + @Test + fun `present - logout`() = runTest { + val presenter = LogoutPreferencePresenter( + FakeMatrixClient(SessionId("sessionId")), + ) + moleculeFlow(RecompositionClock.Immediate) { + presenter.present() + }.test { + val initialState = awaitItem() + initialState.eventSink.invoke(LogoutPreferenceEvents.Logout) + val loadingState = awaitItem() + assertThat(loadingState.logoutAction).isInstanceOf(Async.Loading::class.java) + val successState = awaitItem() + assertThat(successState.logoutAction).isInstanceOf(Async.Success::class.java) + } + } + + @Test + fun `present - logout with error`() = runTest { + val matrixClient = FakeMatrixClient(SessionId("sessionId")) + val presenter = LogoutPreferencePresenter( + matrixClient, + ) + moleculeFlow(RecompositionClock.Immediate) { + presenter.present() + }.test { + val initialState = awaitItem() + matrixClient.givenLogoutError(A_FAILURE) + initialState.eventSink.invoke(LogoutPreferenceEvents.Logout) + val loadingState = awaitItem() + assertThat(loadingState.logoutAction).isInstanceOf(Async.Loading::class.java) + val successState = awaitItem() + assertThat(successState.logoutAction).isEqualTo(Async.Failure(A_FAILURE)) + } + } +} + diff --git a/libraries/matrixtest/src/main/kotlin/io/element/android/libraries/matrixtest/FakeMatrixClient.kt b/libraries/matrixtest/src/main/kotlin/io/element/android/libraries/matrixtest/FakeMatrixClient.kt index fb0490cd64..8cf2056c38 100644 --- a/libraries/matrixtest/src/main/kotlin/io/element/android/libraries/matrixtest/FakeMatrixClient.kt +++ b/libraries/matrixtest/src/main/kotlin/io/element/android/libraries/matrixtest/FakeMatrixClient.kt @@ -26,6 +26,7 @@ import io.element.android.libraries.matrix.room.RoomSummaryDataSource import io.element.android.libraries.matrixtest.media.FakeMediaResolver import io.element.android.libraries.matrixtest.room.FakeMatrixRoom import io.element.android.libraries.matrixtest.room.FakeRoomSummaryDataSource +import kotlinx.coroutines.delay import org.matrix.rustcomponents.sdk.MediaSource class FakeMatrixClient( @@ -33,6 +34,8 @@ class FakeMatrixClient( val roomSummaryDataSource: RoomSummaryDataSource = FakeRoomSummaryDataSource() ) : MatrixClient { + private var logoutFailure: Throwable? = null + override fun getRoom(roomId: RoomId): MatrixRoom? { return FakeMatrixRoom(roomId) } @@ -49,7 +52,14 @@ class FakeMatrixClient( return FakeMediaResolver() } - override suspend fun logout() = Unit + fun givenLogoutError(failure: Throwable) { + logoutFailure = failure + } + + override suspend fun logout() { + delay(100) + logoutFailure?.let { throw it } + } override fun userId(): UserId = UserId("") From d0977d0108a752b96256cdf6ca3d7374b4eea05c Mon Sep 17 00:00:00 2001 From: Benoit Marty Date: Tue, 7 Feb 2023 18:32:00 +0100 Subject: [PATCH 20/51] Add test for `TimelinePresenter` --- features/messages/build.gradle.kts | 7 ++ ...pleUnitTest.kt => MessagePresenterTest.kt} | 27 ++++-- .../features/messages/timeline/Test.kt | 31 +++++++ .../timeline/TimelinePresenterTest.kt | 93 +++++++++++++++++++ .../libraries/matrixtest/FakeMatrixClient.kt | 3 +- .../matrixtest/room/FakeMatrixRoom.kt | 5 +- .../matrixtest/timeline/FakeMatrixTimeline.kt | 13 ++- 7 files changed, 166 insertions(+), 13 deletions(-) rename features/messages/src/test/kotlin/io/element/android/features/messages/{ExampleUnitTest.kt => MessagePresenterTest.kt} (53%) create mode 100644 features/messages/src/test/kotlin/io/element/android/features/messages/timeline/Test.kt create mode 100644 features/messages/src/test/kotlin/io/element/android/features/messages/timeline/TimelinePresenterTest.kt diff --git a/features/messages/build.gradle.kts b/features/messages/build.gradle.kts index 975c7b5c0e..b6f6fe4f33 100644 --- a/features/messages/build.gradle.kts +++ b/features/messages/build.gradle.kts @@ -44,7 +44,14 @@ dependencies { implementation(libs.accompanist.flowlayout) implementation(libs.androidx.recyclerview) implementation(libs.jsoup) + testImplementation(libs.test.junit) + testImplementation(libs.coroutines.test) + testImplementation(libs.molecule.runtime) + testImplementation(libs.test.truth) + testImplementation(libs.test.turbine) + testImplementation(projects.libraries.matrixtest) + androidTestImplementation(libs.test.junitext) ksp(libs.showkase.processor) } diff --git a/features/messages/src/test/kotlin/io/element/android/features/messages/ExampleUnitTest.kt b/features/messages/src/test/kotlin/io/element/android/features/messages/MessagePresenterTest.kt similarity index 53% rename from features/messages/src/test/kotlin/io/element/android/features/messages/ExampleUnitTest.kt rename to features/messages/src/test/kotlin/io/element/android/features/messages/MessagePresenterTest.kt index 83296930a7..c50932f6b8 100644 --- a/features/messages/src/test/kotlin/io/element/android/features/messages/ExampleUnitTest.kt +++ b/features/messages/src/test/kotlin/io/element/android/features/messages/MessagePresenterTest.kt @@ -14,19 +14,28 @@ * limitations under the License. */ +@file:OptIn(ExperimentalCoroutinesApi::class) + package io.element.android.features.messages -import org.junit.Assert.assertEquals +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.test.runTest import org.junit.Test -/** - * Example local unit test, which will execute on the development machine (host). - * - * See [testing documentation](http://d.android.com/tools/testing). - */ -class ExampleUnitTest { +class MessagePresenterTest { @Test - fun addition_isCorrect() { - assertEquals(4, 2 + 2) + fun `present - initial state`() = runTest { + /* + TO BE COMPLETED + val presenter = MessagesPresenter( + FakeMatrixClient(SessionId("sessionId")), + ) + moleculeFlow(RecompositionClock.Immediate) { + presenter.present() + }.test { + val initialState = awaitItem() + assertThat(initialState.logoutAction).isEqualTo(Async.Uninitialized) + } + */ } } diff --git a/features/messages/src/test/kotlin/io/element/android/features/messages/timeline/Test.kt b/features/messages/src/test/kotlin/io/element/android/features/messages/timeline/Test.kt new file mode 100644 index 0000000000..dd6872124b --- /dev/null +++ b/features/messages/src/test/kotlin/io/element/android/features/messages/timeline/Test.kt @@ -0,0 +1,31 @@ +/* + * 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. + */ + +@file:OptIn(ExperimentalCoroutinesApi::class) + +package io.element.android.features.messages.timeline + +import io.element.android.libraries.core.coroutine.CoroutineDispatchers +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.test.UnconfinedTestDispatcher + +// TODO Move to common module to reuse +fun testCoroutineDispatchers() = CoroutineDispatchers( + io = UnconfinedTestDispatcher(), + computation = UnconfinedTestDispatcher(), + main = UnconfinedTestDispatcher(), + diffUpdateDispatcher = UnconfinedTestDispatcher(), +) diff --git a/features/messages/src/test/kotlin/io/element/android/features/messages/timeline/TimelinePresenterTest.kt b/features/messages/src/test/kotlin/io/element/android/features/messages/timeline/TimelinePresenterTest.kt new file mode 100644 index 0000000000..a1106f738e --- /dev/null +++ b/features/messages/src/test/kotlin/io/element/android/features/messages/timeline/TimelinePresenterTest.kt @@ -0,0 +1,93 @@ +/* + * 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. + */ + +@file:OptIn(ExperimentalCoroutinesApi::class) + +package io.element.android.features.messages.timeline + +import app.cash.molecule.RecompositionClock +import app.cash.molecule.moleculeFlow +import app.cash.turbine.test +import com.google.common.truth.Truth.assertThat +import io.element.android.libraries.matrixtest.FakeMatrixClient +import io.element.android.libraries.matrixtest.core.A_ROOM_ID +import io.element.android.libraries.matrixtest.room.FakeMatrixRoom +import io.element.android.libraries.matrixtest.timeline.AN_EVENT_ID +import io.element.android.libraries.matrixtest.timeline.FakeMatrixTimeline +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.test.runTest +import org.junit.Test + +class TimelinePresenterTest { + @Test + fun `present - initial state`() = runTest { + val presenter = TimelinePresenter( + testCoroutineDispatchers(), + FakeMatrixClient(), + FakeMatrixRoom(A_ROOM_ID) + ) + moleculeFlow(RecompositionClock.Immediate) { + presenter.present() + }.test { + val initialState = awaitItem() + assertThat(initialState.timelineItems.size).isEqualTo(0) + } + } + + @Test + fun `present - load more`() = runTest { + val matrixTimeline = FakeMatrixTimeline() + val matrixRoom = FakeMatrixRoom(A_ROOM_ID, matrixTimeline = matrixTimeline) + val presenter = TimelinePresenter( + testCoroutineDispatchers(), + FakeMatrixClient(), + matrixRoom + ) + moleculeFlow(RecompositionClock.Immediate) { + presenter.present() + }.test { + val initialState = awaitItem() + assertThat(initialState.hasMoreToLoad).isTrue() + matrixTimeline.givenHasMoreToLoad(false) + initialState.eventSink.invoke(TimelineEvents.LoadMore) + val loadedState = awaitItem() + assertThat(loadedState.hasMoreToLoad).isFalse() + } + } + + @Test + fun `present - set highlighted event`() = runTest { + val matrixTimeline = FakeMatrixTimeline() + val matrixRoom = FakeMatrixRoom(A_ROOM_ID, matrixTimeline = matrixTimeline) + val presenter = TimelinePresenter( + testCoroutineDispatchers(), + FakeMatrixClient(), + matrixRoom + ) + moleculeFlow(RecompositionClock.Immediate) { + presenter.present() + }.test { + val initialState = awaitItem() + assertThat(initialState.highlightedEventId).isNull() + initialState.eventSink.invoke(TimelineEvents.SetHighlightedEvent(AN_EVENT_ID)) + val withHighlightedState = awaitItem() + assertThat(withHighlightedState.highlightedEventId).isEqualTo(AN_EVENT_ID) + initialState.eventSink.invoke(TimelineEvents.SetHighlightedEvent(null)) + val withoutHighlightedState = awaitItem() + assertThat(withoutHighlightedState.highlightedEventId).isNull() + } + } +} diff --git a/libraries/matrixtest/src/main/kotlin/io/element/android/libraries/matrixtest/FakeMatrixClient.kt b/libraries/matrixtest/src/main/kotlin/io/element/android/libraries/matrixtest/FakeMatrixClient.kt index 8cf2056c38..c7aab0952a 100644 --- a/libraries/matrixtest/src/main/kotlin/io/element/android/libraries/matrixtest/FakeMatrixClient.kt +++ b/libraries/matrixtest/src/main/kotlin/io/element/android/libraries/matrixtest/FakeMatrixClient.kt @@ -23,6 +23,7 @@ import io.element.android.libraries.matrix.core.UserId import io.element.android.libraries.matrix.media.MediaResolver import io.element.android.libraries.matrix.room.MatrixRoom import io.element.android.libraries.matrix.room.RoomSummaryDataSource +import io.element.android.libraries.matrixtest.auth.A_SESSION_ID import io.element.android.libraries.matrixtest.media.FakeMediaResolver import io.element.android.libraries.matrixtest.room.FakeMatrixRoom import io.element.android.libraries.matrixtest.room.FakeRoomSummaryDataSource @@ -30,7 +31,7 @@ import kotlinx.coroutines.delay import org.matrix.rustcomponents.sdk.MediaSource class FakeMatrixClient( - override val sessionId: SessionId, + override val sessionId: SessionId = SessionId(A_SESSION_ID), val roomSummaryDataSource: RoomSummaryDataSource = FakeRoomSummaryDataSource() ) : MatrixClient { diff --git a/libraries/matrixtest/src/main/kotlin/io/element/android/libraries/matrixtest/room/FakeMatrixRoom.kt b/libraries/matrixtest/src/main/kotlin/io/element/android/libraries/matrixtest/room/FakeMatrixRoom.kt index 76da14418d..7e46219126 100644 --- a/libraries/matrixtest/src/main/kotlin/io/element/android/libraries/matrixtest/room/FakeMatrixRoom.kt +++ b/libraries/matrixtest/src/main/kotlin/io/element/android/libraries/matrixtest/room/FakeMatrixRoom.kt @@ -30,7 +30,8 @@ class FakeMatrixRoom( override val bestName: String = "", override val displayName: String = "", override val topic: String? = null, - override val avatarUrl: String? = null + override val avatarUrl: String? = null, + private val matrixTimeline: MatrixTimeline = FakeMatrixTimeline(), ) : MatrixRoom { override fun syncUpdateFlow(): Flow { @@ -38,7 +39,7 @@ class FakeMatrixRoom( } override fun timeline(): MatrixTimeline { - return FakeMatrixTimeline() + return matrixTimeline } override suspend fun userDisplayName(userId: String): Result { diff --git a/libraries/matrixtest/src/main/kotlin/io/element/android/libraries/matrixtest/timeline/FakeMatrixTimeline.kt b/libraries/matrixtest/src/main/kotlin/io/element/android/libraries/matrixtest/timeline/FakeMatrixTimeline.kt index 60fa211b1d..665a2eccfb 100644 --- a/libraries/matrixtest/src/main/kotlin/io/element/android/libraries/matrixtest/timeline/FakeMatrixTimeline.kt +++ b/libraries/matrixtest/src/main/kotlin/io/element/android/libraries/matrixtest/timeline/FakeMatrixTimeline.kt @@ -19,24 +19,35 @@ package io.element.android.libraries.matrixtest.timeline import io.element.android.libraries.matrix.core.EventId import io.element.android.libraries.matrix.timeline.MatrixTimeline import io.element.android.libraries.matrix.timeline.MatrixTimelineItem +import kotlinx.coroutines.delay import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.emptyFlow import org.matrix.rustcomponents.sdk.TimelineListener +const val AN_EVENT_ID_VALUE = "!anEventId" +val AN_EVENT_ID = EventId(AN_EVENT_ID_VALUE) + class FakeMatrixTimeline : MatrixTimeline { override var callback: MatrixTimeline.Callback? get() = null set(value) {} + private var hasMoreToLoadValue: Boolean = true + + fun givenHasMoreToLoad(hasMoreToLoad: Boolean) { + this.hasMoreToLoadValue = hasMoreToLoad + } + override val hasMoreToLoad: Boolean - get() = true + get() = hasMoreToLoadValue override fun timelineItems(): Flow> { return emptyFlow() } override suspend fun paginateBackwards(count: Int): Result { + delay(100) return Result.success(Unit) } From da90d1312cbfae47cbb8414a6d6dc177f3f672d5 Mon Sep 17 00:00:00 2001 From: Benoit Marty Date: Tue, 7 Feb 2023 22:13:39 +0100 Subject: [PATCH 21/51] fix path --- .github/workflows/quality.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/quality.yml b/.github/workflows/quality.yml index 31630acf97..93a2b8c3f4 100644 --- a/.github/workflows/quality.yml +++ b/.github/workflows/quality.yml @@ -37,7 +37,7 @@ jobs: with: name: kover-report path: | - */build/reports/kover/merged/verification/errors.txt + **/kover/merged/verification/errors.txt - name: Prepare Danger if: always() run: | From 8ed858351bf4278f1a40847b917b9134e5d87567 Mon Sep 17 00:00:00 2001 From: Benoit Marty Date: Wed, 8 Feb 2023 09:56:55 +0100 Subject: [PATCH 22/51] Add test for `MessageComposerPresenter` --- .../textcomposer/MessageComposerPresenter.kt | 5 +- .../MessageComposerPresenterTest.kt | 190 ++++++++++++++++++ .../roomlist/RoomListPresenterTests.kt | 4 +- .../libraries/core/data/StableCharSequence.kt | 2 + .../matrixtest/room/FakeMatrixRoom.kt | 4 +- .../matrixtest/room/RoomSummaryFixture.kt | 6 +- .../matrixtest/timeline/FakeMatrixTimeline.kt | 1 + 7 files changed, 205 insertions(+), 7 deletions(-) create mode 100644 features/messages/src/test/kotlin/io/element/android/features/messages/textcomposer/MessageComposerPresenterTest.kt diff --git a/features/messages/src/main/kotlin/io/element/android/features/messages/textcomposer/MessageComposerPresenter.kt b/features/messages/src/main/kotlin/io/element/android/features/messages/textcomposer/MessageComposerPresenter.kt index 29d5030b4e..4f7cbc287f 100644 --- a/features/messages/src/main/kotlin/io/element/android/features/messages/textcomposer/MessageComposerPresenter.kt +++ b/features/messages/src/main/kotlin/io/element/android/features/messages/textcomposer/MessageComposerPresenter.kt @@ -59,7 +59,10 @@ class MessageComposerPresenter @Inject constructor( when (event) { MessageComposerEvents.ToggleFullScreenState -> isFullScreen.value = !isFullScreen.value is MessageComposerEvents.UpdateText -> text.value = event.text.toStableCharSequence() - MessageComposerEvents.CloseSpecialMode -> composerMode.setToNormal() + MessageComposerEvents.CloseSpecialMode -> { + text.value = "".toStableCharSequence() + composerMode.setToNormal() + } is MessageComposerEvents.SendMessage -> appCoroutineScope.sendMessage(event.message, composerMode, text) is MessageComposerEvents.SetMode -> composerMode.value = event.composerMode } diff --git a/features/messages/src/test/kotlin/io/element/android/features/messages/textcomposer/MessageComposerPresenterTest.kt b/features/messages/src/test/kotlin/io/element/android/features/messages/textcomposer/MessageComposerPresenterTest.kt new file mode 100644 index 0000000000..b822caff9e --- /dev/null +++ b/features/messages/src/test/kotlin/io/element/android/features/messages/textcomposer/MessageComposerPresenterTest.kt @@ -0,0 +1,190 @@ +/* + * 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. + */ + +@file:OptIn(ExperimentalCoroutinesApi::class) + +package io.element.android.features.messages.textcomposer + +import app.cash.molecule.RecompositionClock +import app.cash.molecule.moleculeFlow +import app.cash.turbine.ReceiveTurbine +import app.cash.turbine.test +import com.google.common.truth.Truth.assertThat +import io.element.android.libraries.core.data.StableCharSequence +import io.element.android.libraries.matrixtest.core.A_ROOM_ID +import io.element.android.libraries.matrixtest.room.A_MESSAGE +import io.element.android.libraries.matrixtest.room.FakeMatrixRoom +import io.element.android.libraries.matrixtest.timeline.AN_EVENT_ID +import io.element.android.libraries.matrixtest.timeline.A_SENDER_NAME +import io.element.android.libraries.textcomposer.MessageComposerMode +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.test.runTest +import org.junit.Test + +class MessageComposerPresenterTest { + @Test + fun `present - initial state`() = runTest { + val presenter = MessageComposerPresenter( + this, + FakeMatrixRoom(A_ROOM_ID) + ) + moleculeFlow(RecompositionClock.Immediate) { + presenter.present() + }.test { + val initialState = awaitItem() + assertThat(initialState.isFullScreen).isFalse() + assertThat(initialState.text).isEqualTo(StableCharSequence("")) + assertThat(initialState.mode).isEqualTo(MessageComposerMode.Normal("")) + assertThat(initialState.isSendButtonVisible).isFalse() + } + } + + @Test + fun `present - toggle fullscreen`() = runTest { + val presenter = MessageComposerPresenter( + this, + FakeMatrixRoom(A_ROOM_ID) + ) + moleculeFlow(RecompositionClock.Immediate) { + presenter.present() + }.test { + val initialState = awaitItem() + initialState.eventSink.invoke(MessageComposerEvents.ToggleFullScreenState) + val fullscreenState = awaitItem() + assertThat(fullscreenState.isFullScreen).isTrue() + fullscreenState.eventSink.invoke(MessageComposerEvents.ToggleFullScreenState) + val notFullscreenState = awaitItem() + assertThat(notFullscreenState.isFullScreen).isFalse() + } + } + + @Test + fun `present - change message`() = runTest { + val presenter = MessageComposerPresenter( + this, + FakeMatrixRoom(A_ROOM_ID) + ) + moleculeFlow(RecompositionClock.Immediate) { + presenter.present() + }.test { + val initialState = awaitItem() + initialState.eventSink.invoke(MessageComposerEvents.UpdateText(A_MESSAGE)) + val withMessageState = awaitItem() + assertThat(withMessageState.text).isEqualTo(StableCharSequence(A_MESSAGE)) + assertThat(withMessageState.isSendButtonVisible).isTrue() + withMessageState.eventSink.invoke(MessageComposerEvents.UpdateText("")) + val withEmptyMessageState = awaitItem() + assertThat(withEmptyMessageState.text).isEqualTo(StableCharSequence("")) + assertThat(withEmptyMessageState.isSendButtonVisible).isFalse() + } + } + + @Test + fun `present - change mode to edit`() = runTest { + val presenter = MessageComposerPresenter( + this, + FakeMatrixRoom(A_ROOM_ID) + ) + moleculeFlow(RecompositionClock.Immediate) { + presenter.present() + }.test { + var state = awaitItem() + val mode = anEditMode() + state.eventSink.invoke(MessageComposerEvents.SetMode(mode)) + state = awaitItem() + assertThat(state.mode).isEqualTo(mode) + state = awaitItem() + assertThat(state.text).isEqualTo(StableCharSequence(A_MESSAGE)) + assertThat(state.isSendButtonVisible).isTrue() + backToNormalMode(state, skipCount = 1) + } + } + + private suspend fun ReceiveTurbine.backToNormalMode(state: MessageComposerState, skipCount: Int = 0) { + state.eventSink.invoke(MessageComposerEvents.CloseSpecialMode) + skipItems(skipCount) + val normalState = awaitItem() + assertThat(normalState.mode).isEqualTo(MessageComposerMode.Normal("")) + assertThat(normalState.text).isEqualTo(StableCharSequence("")) + assertThat(normalState.isSendButtonVisible).isFalse() + } + + @Test + fun `present - change mode to reply`() = runTest { + val presenter = MessageComposerPresenter( + this, + FakeMatrixRoom(A_ROOM_ID) + ) + moleculeFlow(RecompositionClock.Immediate) { + presenter.present() + }.test { + var state = awaitItem() + val mode = aReplyMode() + state.eventSink.invoke(MessageComposerEvents.SetMode(mode)) + state = awaitItem() + assertThat(state.mode).isEqualTo(mode) + assertThat(state.text).isEqualTo(StableCharSequence("")) + assertThat(state.isSendButtonVisible).isFalse() + backToNormalMode(state) + } + } + + @Test + fun `present - change mode to quote`() = runTest { + val presenter = MessageComposerPresenter( + this, + FakeMatrixRoom(A_ROOM_ID) + ) + moleculeFlow(RecompositionClock.Immediate) { + presenter.present() + }.test { + var state = awaitItem() + val mode = aQuoteMode() + state.eventSink.invoke(MessageComposerEvents.SetMode(mode)) + state = awaitItem() + assertThat(state.mode).isEqualTo(mode) + assertThat(state.text).isEqualTo(StableCharSequence("")) + assertThat(state.isSendButtonVisible).isFalse() + backToNormalMode(state) + } + } + + @Test + fun `present - send message`() = runTest { + val presenter = MessageComposerPresenter( + this, + FakeMatrixRoom(A_ROOM_ID) + ) + moleculeFlow(RecompositionClock.Immediate) { + presenter.present() + }.test { + val initialState = awaitItem() + initialState.eventSink.invoke(MessageComposerEvents.UpdateText(A_MESSAGE)) + val withMessageState = awaitItem() + assertThat(withMessageState.text).isEqualTo(StableCharSequence(A_MESSAGE)) + assertThat(withMessageState.isSendButtonVisible).isTrue() + withMessageState.eventSink.invoke(MessageComposerEvents.SendMessage(A_MESSAGE)) + val messageSentState = awaitItem() + assertThat(messageSentState.text).isEqualTo(StableCharSequence("")) + assertThat(messageSentState.isSendButtonVisible).isFalse() + } + } + +} + +fun anEditMode() = MessageComposerMode.Edit(AN_EVENT_ID, A_MESSAGE) +fun aReplyMode() = MessageComposerMode.Reply(A_SENDER_NAME, AN_EVENT_ID, A_MESSAGE) +fun aQuoteMode() = MessageComposerMode.Quote(AN_EVENT_ID, A_MESSAGE) diff --git a/features/roomlist/src/test/kotlin/io/element/android/features/roomlist/RoomListPresenterTests.kt b/features/roomlist/src/test/kotlin/io/element/android/features/roomlist/RoomListPresenterTests.kt index baa547b4bb..46c1597475 100644 --- a/features/roomlist/src/test/kotlin/io/element/android/features/roomlist/RoomListPresenterTests.kt +++ b/features/roomlist/src/test/kotlin/io/element/android/features/roomlist/RoomListPresenterTests.kt @@ -30,7 +30,7 @@ import io.element.android.libraries.matrix.core.SessionId import io.element.android.libraries.matrixtest.FakeMatrixClient import io.element.android.libraries.matrixtest.core.A_ROOM_ID import io.element.android.libraries.matrixtest.core.A_ROOM_ID_VALUE -import io.element.android.libraries.matrixtest.room.A_LAST_MESSAGE +import io.element.android.libraries.matrixtest.room.A_MESSAGE import io.element.android.libraries.matrixtest.room.A_ROOM_NAME import io.element.android.libraries.matrixtest.room.FakeRoomSummaryDataSource import io.element.android.libraries.matrixtest.room.aRoomSummaryFilled @@ -187,7 +187,7 @@ private val aRoomListRoomSummary = RoomListRoomSummary( name = A_ROOM_NAME, hasUnread = true, timestamp = A_FORMATTED_DATE, - lastMessage = A_LAST_MESSAGE, + lastMessage = A_MESSAGE, avatarData = AvatarData(name = A_ROOM_NAME), isPlaceholder = false, ) diff --git a/libraries/core/src/main/kotlin/io/element/android/libraries/core/data/StableCharSequence.kt b/libraries/core/src/main/kotlin/io/element/android/libraries/core/data/StableCharSequence.kt index 25f68f2fea..e4ffe2dcaa 100644 --- a/libraries/core/src/main/kotlin/io/element/android/libraries/core/data/StableCharSequence.kt +++ b/libraries/core/src/main/kotlin/io/element/android/libraries/core/data/StableCharSequence.kt @@ -24,6 +24,8 @@ class StableCharSequence(val charSequence: CharSequence) { override fun hashCode() = hash override fun equals(other: Any?) = other is StableCharSequence && other.hash == hash + + override fun toString(): String = "StableCharSequence(\"$charSequence\")" } fun CharSequence.toStableCharSequence() = StableCharSequence(this) diff --git a/libraries/matrixtest/src/main/kotlin/io/element/android/libraries/matrixtest/room/FakeMatrixRoom.kt b/libraries/matrixtest/src/main/kotlin/io/element/android/libraries/matrixtest/room/FakeMatrixRoom.kt index 7e46219126..7aac3b95a0 100644 --- a/libraries/matrixtest/src/main/kotlin/io/element/android/libraries/matrixtest/room/FakeMatrixRoom.kt +++ b/libraries/matrixtest/src/main/kotlin/io/element/android/libraries/matrixtest/room/FakeMatrixRoom.kt @@ -21,6 +21,7 @@ import io.element.android.libraries.matrix.core.RoomId import io.element.android.libraries.matrix.room.MatrixRoom import io.element.android.libraries.matrix.timeline.MatrixTimeline import io.element.android.libraries.matrixtest.timeline.FakeMatrixTimeline +import kotlinx.coroutines.delay import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.emptyFlow @@ -51,7 +52,8 @@ class FakeMatrixRoom( } override suspend fun sendMessage(message: String): Result { - TODO("Not yet implemented") + delay(100) + return Result.success(Unit) } override suspend fun editMessage(originalEventId: EventId, message: String): Result { diff --git a/libraries/matrixtest/src/main/kotlin/io/element/android/libraries/matrixtest/room/RoomSummaryFixture.kt b/libraries/matrixtest/src/main/kotlin/io/element/android/libraries/matrixtest/room/RoomSummaryFixture.kt index 0bed866300..79d65536bd 100644 --- a/libraries/matrixtest/src/main/kotlin/io/element/android/libraries/matrixtest/room/RoomSummaryFixture.kt +++ b/libraries/matrixtest/src/main/kotlin/io/element/android/libraries/matrixtest/room/RoomSummaryFixture.kt @@ -22,14 +22,14 @@ import io.element.android.libraries.matrix.room.RoomSummaryDetails import io.element.android.libraries.matrixtest.core.A_ROOM_ID const val A_ROOM_NAME = "aRoomName" -const val A_LAST_MESSAGE = "Last message" +const val A_MESSAGE = "Hello world!" fun aRoomSummaryFilled( roomId: RoomId = A_ROOM_ID, name: String = A_ROOM_NAME, isDirect: Boolean = false, avatarURLString: String? = null, - lastMessage: CharSequence? = A_LAST_MESSAGE, + lastMessage: CharSequence? = A_MESSAGE, lastMessageTimestamp: Long? = null, unreadNotificationCount: Int = 2, ) = RoomSummary.Filled( @@ -49,7 +49,7 @@ fun aRoomSummaryDetail( name: String = A_ROOM_NAME, isDirect: Boolean = false, avatarURLString: String? = null, - lastMessage: CharSequence? = A_LAST_MESSAGE, + lastMessage: CharSequence? = A_MESSAGE, lastMessageTimestamp: Long? = null, unreadNotificationCount: Int = 2, ) = RoomSummaryDetails( diff --git a/libraries/matrixtest/src/main/kotlin/io/element/android/libraries/matrixtest/timeline/FakeMatrixTimeline.kt b/libraries/matrixtest/src/main/kotlin/io/element/android/libraries/matrixtest/timeline/FakeMatrixTimeline.kt index 665a2eccfb..17eb98cb50 100644 --- a/libraries/matrixtest/src/main/kotlin/io/element/android/libraries/matrixtest/timeline/FakeMatrixTimeline.kt +++ b/libraries/matrixtest/src/main/kotlin/io/element/android/libraries/matrixtest/timeline/FakeMatrixTimeline.kt @@ -24,6 +24,7 @@ import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.emptyFlow import org.matrix.rustcomponents.sdk.TimelineListener +const val A_SENDER_NAME = "Alice" const val AN_EVENT_ID_VALUE = "!anEventId" val AN_EVENT_ID = EventId(AN_EVENT_ID_VALUE) From 67201a1af99412277d77e6fcda5dcf7acf508f91 Mon Sep 17 00:00:00 2001 From: Benoit Marty Date: Wed, 8 Feb 2023 14:30:19 +0100 Subject: [PATCH 23/51] Convert RageshakeDataStore to an interface for testing purpose. --- .../PreferencesRageshakeDataStore.kt | 72 +++++++++++++++++++ .../rageshake/rageshake/RageshakeDataStore.kt | 52 ++------------ 2 files changed, 79 insertions(+), 45 deletions(-) create mode 100644 features/rageshake/src/main/kotlin/io/element/android/features/rageshake/rageshake/PreferencesRageshakeDataStore.kt diff --git a/features/rageshake/src/main/kotlin/io/element/android/features/rageshake/rageshake/PreferencesRageshakeDataStore.kt b/features/rageshake/src/main/kotlin/io/element/android/features/rageshake/rageshake/PreferencesRageshakeDataStore.kt new file mode 100644 index 0000000000..4643038536 --- /dev/null +++ b/features/rageshake/src/main/kotlin/io/element/android/features/rageshake/rageshake/PreferencesRageshakeDataStore.kt @@ -0,0 +1,72 @@ +/* + * Copyright (c) 2022 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.features.rageshake.rageshake + +import android.content.Context +import androidx.datastore.core.DataStore +import androidx.datastore.preferences.core.Preferences +import androidx.datastore.preferences.core.booleanPreferencesKey +import androidx.datastore.preferences.core.edit +import androidx.datastore.preferences.core.floatPreferencesKey +import androidx.datastore.preferences.preferencesDataStore +import com.squareup.anvil.annotations.ContributesBinding +import io.element.android.libraries.core.bool.orTrue +import io.element.android.libraries.di.AppScope +import io.element.android.libraries.di.ApplicationContext +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.map +import javax.inject.Inject + +private val Context.dataStore: DataStore by preferencesDataStore(name = "elementx_rageshake") + +private val enabledKey = booleanPreferencesKey("enabled") +private val sensitivityKey = floatPreferencesKey("sensitivity") + +@ContributesBinding(AppScope::class) +class PreferencesRageshakeDataStore @Inject constructor( + @ApplicationContext context: Context +) : RageshakeDataStore { + private val store = context.dataStore + + override fun isEnabled(): Flow { + return store.data.map { prefs -> + prefs[enabledKey].orTrue() + } + } + + override suspend fun setIsEnabled(isEnabled: Boolean) { + store.edit { prefs -> + prefs[enabledKey] = isEnabled + } + } + + override fun sensitivity(): Flow { + return store.data.map { prefs -> + prefs[sensitivityKey] ?: 0.5f + } + } + + override suspend fun setSensitivity(sensitivity: Float) { + store.edit { prefs -> + prefs[sensitivityKey] = sensitivity + } + } + + override suspend fun reset() { + store.edit { it.clear() } + } +} diff --git a/features/rageshake/src/main/kotlin/io/element/android/features/rageshake/rageshake/RageshakeDataStore.kt b/features/rageshake/src/main/kotlin/io/element/android/features/rageshake/rageshake/RageshakeDataStore.kt index 1bf133d42f..25a7080354 100644 --- a/features/rageshake/src/main/kotlin/io/element/android/features/rageshake/rageshake/RageshakeDataStore.kt +++ b/features/rageshake/src/main/kotlin/io/element/android/features/rageshake/rageshake/RageshakeDataStore.kt @@ -1,5 +1,5 @@ /* - * Copyright (c) 2022 New Vector Ltd + * 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. @@ -16,54 +16,16 @@ package io.element.android.features.rageshake.rageshake -import android.content.Context -import androidx.datastore.core.DataStore -import androidx.datastore.preferences.core.Preferences -import androidx.datastore.preferences.core.booleanPreferencesKey -import androidx.datastore.preferences.core.edit -import androidx.datastore.preferences.core.floatPreferencesKey -import androidx.datastore.preferences.preferencesDataStore -import io.element.android.libraries.core.bool.orTrue -import io.element.android.libraries.di.ApplicationContext import kotlinx.coroutines.flow.Flow -import kotlinx.coroutines.flow.map -import javax.inject.Inject -private val Context.dataStore: DataStore by preferencesDataStore(name = "elementx_rageshake") +interface RageshakeDataStore { + fun isEnabled(): Flow -private val enabledKey = booleanPreferencesKey("enabled") -private val sensitivityKey = floatPreferencesKey("sensitivity") + suspend fun setIsEnabled(isEnabled: Boolean) -class RageshakeDataStore @Inject constructor( - @ApplicationContext context: Context -) { - private val store = context.dataStore + fun sensitivity(): Flow - fun isEnabled(): Flow { - return store.data.map { prefs -> - prefs[enabledKey].orTrue() - } - } + suspend fun setSensitivity(sensitivity: Float) - suspend fun setIsEnabled(isEnabled: Boolean) { - store.edit { prefs -> - prefs[enabledKey] = isEnabled - } - } - - fun sensitivity(): Flow { - return store.data.map { prefs -> - prefs[sensitivityKey] ?: 0.5f - } - } - - suspend fun setSensitivity(sensitivity: Float) { - store.edit { prefs -> - prefs[sensitivityKey] = sensitivity - } - } - - suspend fun reset() { - store.edit { it.clear() } - } + suspend fun reset() } From 4e25e979769d8bfbb9a73dcaf6579a546c4849b5 Mon Sep 17 00:00:00 2001 From: Benoit Marty Date: Wed, 8 Feb 2023 14:36:48 +0100 Subject: [PATCH 24/51] Convert Rageshake to an interface for testing purpose. --- app/build.gradle.kts | 1 + .../detection/RageshakeDetectionPresenter.kt | 4 +- .../rageshake/rageshake/DefaultRageShake.kt | 77 +++++++++++++++++++ .../features/rageshake/rageshake/RageShake.kt | 50 ++---------- 4 files changed, 87 insertions(+), 45 deletions(-) create mode 100644 features/rageshake/src/main/kotlin/io/element/android/features/rageshake/rageshake/DefaultRageShake.kt diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 4aa95c2f7c..305173d99f 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -176,6 +176,7 @@ dependencies { implementation(libs.androidx.startup) implementation(libs.coil) implementation(libs.datetime) + implementation(libs.squareup.seismic) implementation(libs.dagger) kapt(libs.dagger.compiler) diff --git a/features/rageshake/src/main/kotlin/io/element/android/features/rageshake/detection/RageshakeDetectionPresenter.kt b/features/rageshake/src/main/kotlin/io/element/android/features/rageshake/detection/RageshakeDetectionPresenter.kt index c91d584021..dc0155877e 100644 --- a/features/rageshake/src/main/kotlin/io/element/android/features/rageshake/detection/RageshakeDetectionPresenter.kt +++ b/features/rageshake/src/main/kotlin/io/element/android/features/rageshake/detection/RageshakeDetectionPresenter.kt @@ -95,12 +95,12 @@ class RageshakeDetectionPresenter @Inject constructor( private fun handleRageShake(start: Boolean, state: RageshakeDetectionState, takeScreenshot: MutableState) { if (start) { rageShake.start(state.preferenceState.sensitivity) - rageShake.interceptor = { + rageShake.setInterceptor { takeScreenshot.value = true } } else { rageShake.stop() - rageShake.interceptor = null + rageShake.setInterceptor(null) } } diff --git a/features/rageshake/src/main/kotlin/io/element/android/features/rageshake/rageshake/DefaultRageShake.kt b/features/rageshake/src/main/kotlin/io/element/android/features/rageshake/rageshake/DefaultRageShake.kt new file mode 100644 index 0000000000..a0963000d8 --- /dev/null +++ b/features/rageshake/src/main/kotlin/io/element/android/features/rageshake/rageshake/DefaultRageShake.kt @@ -0,0 +1,77 @@ +/* + * Copyright (c) 2022 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.features.rageshake.rageshake + +import android.content.Context +import android.hardware.Sensor +import android.hardware.SensorManager +import androidx.core.content.getSystemService +import com.squareup.anvil.annotations.ContributesBinding +import com.squareup.seismic.ShakeDetector +import io.element.android.libraries.di.AppScope +import io.element.android.libraries.di.ApplicationContext +import io.element.android.libraries.di.SingleIn +import javax.inject.Inject + +@SingleIn(AppScope::class) +@ContributesBinding(AppScope::class, RageShake::class) +class DefaultRageShake @Inject constructor( + @ApplicationContext context: Context, +) : ShakeDetector.Listener, RageShake { + + private var sensorManager = context.getSystemService() + private var shakeDetector: ShakeDetector? = null + private var interceptor: (() -> Unit)? = null + + override fun setInterceptor(interceptor: (() -> Unit)?) { + this.interceptor = interceptor + } + + /** + * Check if the feature is available on this device. + */ + override fun isAvailable(): Boolean { + return sensorManager?.getDefaultSensor(Sensor.TYPE_ACCELEROMETER) != null + } + + override fun start(sensitivity: Float) { + sensorManager?.let { + shakeDetector = ShakeDetector(this).apply { + start(it, SensorManager.SENSOR_DELAY_GAME) + } + setSensitivity(sensitivity) + } + } + + override fun stop() { + shakeDetector?.stop() + } + + /** + * sensitivity will be {0, O.25, 0.5, 0.75, 1} and converted to + * [ShakeDetector.SENSITIVITY_LIGHT (=11), ShakeDetector.SENSITIVITY_HARD (=15)]. + */ + override fun setSensitivity(sensitivity: Float) { + shakeDetector?.setSensitivity( + ShakeDetector.SENSITIVITY_LIGHT + (sensitivity * 4).toInt() + ) + } + + override fun hearShake() { + interceptor?.invoke() + } +} diff --git a/features/rageshake/src/main/kotlin/io/element/android/features/rageshake/rageshake/RageShake.kt b/features/rageshake/src/main/kotlin/io/element/android/features/rageshake/rageshake/RageShake.kt index 691da5dbe2..d9150b5ecd 100644 --- a/features/rageshake/src/main/kotlin/io/element/android/features/rageshake/rageshake/RageShake.kt +++ b/features/rageshake/src/main/kotlin/io/element/android/features/rageshake/rageshake/RageShake.kt @@ -1,5 +1,5 @@ /* - * Copyright (c) 2022 New Vector Ltd + * 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. @@ -16,57 +16,21 @@ package io.element.android.features.rageshake.rageshake -import android.content.Context -import android.hardware.Sensor -import android.hardware.SensorManager -import androidx.core.content.getSystemService -import com.squareup.seismic.ShakeDetector -import io.element.android.libraries.di.AppScope -import io.element.android.libraries.di.ApplicationContext -import io.element.android.libraries.di.SingleIn -import javax.inject.Inject - -@SingleIn(AppScope::class) -class RageShake @Inject constructor( - @ApplicationContext context: Context, -) : ShakeDetector.Listener { - - private var sensorManager = context.getSystemService() - private var shakeDetector: ShakeDetector? = null - - var interceptor: (() -> Unit)? = null - +interface RageShake { /** * Check if the feature is available on this device. */ - fun isAvailable(): Boolean { - return sensorManager?.getDefaultSensor(Sensor.TYPE_ACCELEROMETER) != null - } + fun isAvailable(): Boolean - fun start(sensitivity: Float) { - sensorManager?.let { - shakeDetector = ShakeDetector(this).apply { - start(it, SensorManager.SENSOR_DELAY_GAME) - } - setSensitivity(sensitivity) - } - } + fun start(sensitivity: Float) - fun stop() { - shakeDetector?.stop() - } + fun stop() /** * sensitivity will be {0, O.25, 0.5, 0.75, 1} and converted to * [ShakeDetector.SENSITIVITY_LIGHT (=11), ShakeDetector.SENSITIVITY_HARD (=15)]. */ - fun setSensitivity(sensitivity: Float) { - shakeDetector?.setSensitivity( - ShakeDetector.SENSITIVITY_LIGHT + (sensitivity * 4).toInt() - ) - } + fun setSensitivity(sensitivity: Float) - override fun hearShake() { - interceptor?.invoke() - } + fun setInterceptor(interceptor: (() -> Unit)?) } From c683426ce4908b685f34f02532134d4a4cd2290d Mon Sep 17 00:00:00 2001 From: Benoit Marty Date: Wed, 8 Feb 2023 15:06:39 +0100 Subject: [PATCH 25/51] Add test for `RageshakePreferencesPresenter` --- features/rageshake/build.gradle.kts | 7 ++ .../preferences/FakeRageShake.kt} | 34 ++++--- .../preferences/FakeRageshakeDataStore.kt | 43 ++++++++ .../RageshakePreferencesPresenterTest.kt | 98 +++++++++++++++++++ 4 files changed, 169 insertions(+), 13 deletions(-) rename features/rageshake/src/test/kotlin/io/element/android/features/{login/ExampleUnitTest.kt => rageshake/preferences/FakeRageShake.kt} (51%) create mode 100644 features/rageshake/src/test/kotlin/io/element/android/features/rageshake/preferences/FakeRageshakeDataStore.kt create mode 100644 features/rageshake/src/test/kotlin/io/element/android/features/rageshake/preferences/RageshakePreferencesPresenterTest.kt diff --git a/features/rageshake/build.gradle.kts b/features/rageshake/build.gradle.kts index ecb333289b..d1aee8d989 100644 --- a/features/rageshake/build.gradle.kts +++ b/features/rageshake/build.gradle.kts @@ -45,6 +45,13 @@ dependencies { implementation(libs.coil) implementation(libs.coil.compose) ksp(libs.showkase.processor) + testImplementation(libs.test.junit) + testImplementation(libs.coroutines.test) + testImplementation(libs.molecule.runtime) + testImplementation(libs.test.truth) + testImplementation(libs.test.turbine) + testImplementation(projects.libraries.matrixtest) + androidTestImplementation(libs.test.junitext) } diff --git a/features/rageshake/src/test/kotlin/io/element/android/features/login/ExampleUnitTest.kt b/features/rageshake/src/test/kotlin/io/element/android/features/rageshake/preferences/FakeRageShake.kt similarity index 51% rename from features/rageshake/src/test/kotlin/io/element/android/features/login/ExampleUnitTest.kt rename to features/rageshake/src/test/kotlin/io/element/android/features/rageshake/preferences/FakeRageShake.kt index ee6363e624..6d0669cd7c 100644 --- a/features/rageshake/src/test/kotlin/io/element/android/features/login/ExampleUnitTest.kt +++ b/features/rageshake/src/test/kotlin/io/element/android/features/rageshake/preferences/FakeRageShake.kt @@ -1,5 +1,5 @@ /* - * Copyright (c) 2022 New Vector Ltd + * 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. @@ -14,19 +14,27 @@ * limitations under the License. */ -package io.element.android.features.login +package io.element.android.features.rageshake.preferences -import org.junit.Assert.assertEquals -import org.junit.Test +import io.element.android.features.rageshake.rageshake.RageShake -/** - * Example local unit test, which will execute on the development machine (host). - * - * See [testing documentation](http://d.android.com/tools/testing). - */ -class ExampleUnitTest { - @Test - fun addition_isCorrect() { - assertEquals(4, 2 + 2) +const val A_SENSITIVITY = 1f + +class FakeRageShake( + private var isAvailableValue: Boolean = true +) : RageShake { + + override fun isAvailable() = isAvailableValue + + override fun start(sensitivity: Float) { + } + + override fun stop() { + } + + override fun setSensitivity(sensitivity: Float) { + } + + override fun setInterceptor(interceptor: (() -> Unit)?) { } } diff --git a/features/rageshake/src/test/kotlin/io/element/android/features/rageshake/preferences/FakeRageshakeDataStore.kt b/features/rageshake/src/test/kotlin/io/element/android/features/rageshake/preferences/FakeRageshakeDataStore.kt new file mode 100644 index 0000000000..22c4ae4d4d --- /dev/null +++ b/features/rageshake/src/test/kotlin/io/element/android/features/rageshake/preferences/FakeRageshakeDataStore.kt @@ -0,0 +1,43 @@ +/* + * 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.features.rageshake.preferences + +import io.element.android.features.rageshake.rageshake.RageshakeDataStore +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.MutableStateFlow + +class FakeRageshakeDataStore( + isEnabled: Boolean = true, + sensitivity: Float = A_SENSITIVITY, +) : RageshakeDataStore { + + private val isEnabledFlow = MutableStateFlow(isEnabled) + override fun isEnabled(): Flow = isEnabledFlow + + override suspend fun setIsEnabled(isEnabled: Boolean) { + isEnabledFlow.value = isEnabled + } + + private val sensitivityFlow = MutableStateFlow(sensitivity) + override fun sensitivity(): Flow = sensitivityFlow + + override suspend fun setSensitivity(sensitivity: Float) { + sensitivityFlow.value = sensitivity + } + + override suspend fun reset() = Unit +} diff --git a/features/rageshake/src/test/kotlin/io/element/android/features/rageshake/preferences/RageshakePreferencesPresenterTest.kt b/features/rageshake/src/test/kotlin/io/element/android/features/rageshake/preferences/RageshakePreferencesPresenterTest.kt new file mode 100644 index 0000000000..17a46e8da6 --- /dev/null +++ b/features/rageshake/src/test/kotlin/io/element/android/features/rageshake/preferences/RageshakePreferencesPresenterTest.kt @@ -0,0 +1,98 @@ +/* + * 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. + */ + +@file:OptIn(ExperimentalCoroutinesApi::class) + +package io.element.android.features.rageshake.preferences + +import app.cash.molecule.RecompositionClock +import app.cash.molecule.moleculeFlow +import app.cash.turbine.test +import com.google.common.truth.Truth.assertThat +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.test.runTest +import org.junit.Test + +class RageshakePreferencesPresenterTest { + @Test + fun `present - initial state available`() = runTest { + val presenter = RageshakePreferencesPresenter( + FakeRageShake(isAvailableValue = true), + FakeRageshakeDataStore(isEnabled = true) + ) + moleculeFlow(RecompositionClock.Immediate) { + presenter.present() + }.test { + skipItems(1) + val initialState = awaitItem() + assertThat(initialState.isSupported).isTrue() + assertThat(initialState.isEnabled).isTrue() + } + } + + @Test + fun `present - initial state not available`() = runTest { + val presenter = RageshakePreferencesPresenter( + FakeRageShake(isAvailableValue = false), + FakeRageshakeDataStore(isEnabled = true) + ) + moleculeFlow(RecompositionClock.Immediate) { + presenter.present() + }.test { + skipItems(1) + val initialState = awaitItem() + assertThat(initialState.isSupported).isFalse() + assertThat(initialState.isEnabled).isTrue() + } + } + + @Test + fun `present - enable and disable`() = runTest { + val presenter = RageshakePreferencesPresenter( + FakeRageShake(isAvailableValue = true), + FakeRageshakeDataStore(isEnabled = true) + ) + moleculeFlow(RecompositionClock.Immediate) { + presenter.present() + }.test { + skipItems(1) + val initialState = awaitItem() + assertThat(initialState.isEnabled).isTrue() + initialState.eventSink.invoke(RageshakePreferencesEvents.SetIsEnabled(false)) + assertThat(awaitItem().isEnabled).isFalse() + initialState.eventSink.invoke(RageshakePreferencesEvents.SetIsEnabled(true)) + assertThat(awaitItem().isEnabled).isTrue() + } + } + + @Test + fun `present - set sensitivity`() = runTest { + val presenter = RageshakePreferencesPresenter( + FakeRageShake(isAvailableValue = true), + FakeRageshakeDataStore(isEnabled = true) + ) + moleculeFlow(RecompositionClock.Immediate) { + presenter.present() + }.test { + skipItems(1) + val initialState = awaitItem() + assertThat(initialState.sensitivity).isEqualTo(A_SENSITIVITY) + initialState.eventSink.invoke(RageshakePreferencesEvents.SetSensitivity(A_SENSITIVITY + 1f)) + assertThat(awaitItem().sensitivity).isEqualTo(A_SENSITIVITY + 1f) + } + } +} + From 0aea3d50e5b5db00638107b1ede6811a9f96eca5 Mon Sep 17 00:00:00 2001 From: Benoit Marty Date: Wed, 8 Feb 2023 15:11:06 +0100 Subject: [PATCH 26/51] Convert CrashDataStore to an interface for testing purpose. --- .../rageshake/crash/CrashDataStore.kt | 59 ++------------ .../crash/PreferencesCrashDataStore.kt | 77 +++++++++++++++++++ .../crash/VectorUncaughtExceptionHandler.kt | 2 +- 3 files changed, 85 insertions(+), 53 deletions(-) create mode 100644 features/rageshake/src/main/kotlin/io/element/android/features/rageshake/crash/PreferencesCrashDataStore.kt diff --git a/features/rageshake/src/main/kotlin/io/element/android/features/rageshake/crash/CrashDataStore.kt b/features/rageshake/src/main/kotlin/io/element/android/features/rageshake/crash/CrashDataStore.kt index 5038d520b2..d9326841d8 100644 --- a/features/rageshake/src/main/kotlin/io/element/android/features/rageshake/crash/CrashDataStore.kt +++ b/features/rageshake/src/main/kotlin/io/element/android/features/rageshake/crash/CrashDataStore.kt @@ -1,5 +1,5 @@ /* - * Copyright (c) 2022 New Vector Ltd + * 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. @@ -16,59 +16,14 @@ package io.element.android.features.rageshake.crash -import android.content.Context -import androidx.datastore.core.DataStore -import androidx.datastore.preferences.core.Preferences -import androidx.datastore.preferences.core.booleanPreferencesKey -import androidx.datastore.preferences.core.edit -import androidx.datastore.preferences.core.stringPreferencesKey -import androidx.datastore.preferences.preferencesDataStore -import io.element.android.libraries.core.bool.orFalse -import io.element.android.libraries.di.ApplicationContext import kotlinx.coroutines.flow.Flow -import kotlinx.coroutines.flow.map -import kotlinx.coroutines.runBlocking -import javax.inject.Inject -private val Context.dataStore: DataStore by preferencesDataStore(name = "elementx_crash") +interface CrashDataStore { + fun setCrashData(crashData: String) -private val appHasCrashedKey = booleanPreferencesKey("appHasCrashed") -private val crashDataKey = stringPreferencesKey("crashData") + suspend fun resetAppHasCrashed() + fun appHasCrashed(): Flow + fun crashInfo(): Flow -class CrashDataStore @Inject constructor( - @ApplicationContext context: Context -) { - private val store = context.dataStore - - fun setCrashData(crashData: String) { - // Must block - runBlocking { - store.edit { prefs -> - prefs[appHasCrashedKey] = true - prefs[crashDataKey] = crashData - } - } - } - - suspend fun resetAppHasCrashed() { - store.edit { prefs -> - prefs[appHasCrashedKey] = false - } - } - - fun appHasCrashed(): Flow { - return store.data.map { prefs -> - prefs[appHasCrashedKey].orFalse() - } - } - - fun crashInfo(): Flow { - return store.data.map { prefs -> - prefs[crashDataKey].orEmpty() - } - } - - suspend fun reset() { - store.edit { it.clear() } - } + suspend fun reset() } diff --git a/features/rageshake/src/main/kotlin/io/element/android/features/rageshake/crash/PreferencesCrashDataStore.kt b/features/rageshake/src/main/kotlin/io/element/android/features/rageshake/crash/PreferencesCrashDataStore.kt new file mode 100644 index 0000000000..70f258fd02 --- /dev/null +++ b/features/rageshake/src/main/kotlin/io/element/android/features/rageshake/crash/PreferencesCrashDataStore.kt @@ -0,0 +1,77 @@ +/* + * Copyright (c) 2022 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.features.rageshake.crash + +import android.content.Context +import androidx.datastore.core.DataStore +import androidx.datastore.preferences.core.Preferences +import androidx.datastore.preferences.core.booleanPreferencesKey +import androidx.datastore.preferences.core.edit +import androidx.datastore.preferences.core.stringPreferencesKey +import androidx.datastore.preferences.preferencesDataStore +import com.squareup.anvil.annotations.ContributesBinding +import io.element.android.libraries.core.bool.orFalse +import io.element.android.libraries.di.AppScope +import io.element.android.libraries.di.ApplicationContext +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.runBlocking +import javax.inject.Inject + +private val Context.dataStore: DataStore by preferencesDataStore(name = "elementx_crash") + +private val appHasCrashedKey = booleanPreferencesKey("appHasCrashed") +private val crashDataKey = stringPreferencesKey("crashData") + +@ContributesBinding(AppScope::class) +class PreferencesCrashDataStore @Inject constructor( + @ApplicationContext context: Context +) : CrashDataStore { + private val store = context.dataStore + + override fun setCrashData(crashData: String) { + // Must block + runBlocking { + store.edit { prefs -> + prefs[appHasCrashedKey] = true + prefs[crashDataKey] = crashData + } + } + } + + override suspend fun resetAppHasCrashed() { + store.edit { prefs -> + prefs[appHasCrashedKey] = false + } + } + + override fun appHasCrashed(): Flow { + return store.data.map { prefs -> + prefs[appHasCrashedKey].orFalse() + } + } + + override fun crashInfo(): Flow { + return store.data.map { prefs -> + prefs[crashDataKey].orEmpty() + } + } + + override suspend fun reset() { + store.edit { it.clear() } + } +} diff --git a/features/rageshake/src/main/kotlin/io/element/android/features/rageshake/crash/VectorUncaughtExceptionHandler.kt b/features/rageshake/src/main/kotlin/io/element/android/features/rageshake/crash/VectorUncaughtExceptionHandler.kt index dfd09a203e..942d49d531 100644 --- a/features/rageshake/src/main/kotlin/io/element/android/features/rageshake/crash/VectorUncaughtExceptionHandler.kt +++ b/features/rageshake/src/main/kotlin/io/element/android/features/rageshake/crash/VectorUncaughtExceptionHandler.kt @@ -26,7 +26,7 @@ import java.io.StringWriter class VectorUncaughtExceptionHandler( context: Context ) : Thread.UncaughtExceptionHandler { - private val crashDataStore = CrashDataStore(context) + private val crashDataStore = PreferencesCrashDataStore(context) private var previousHandler: Thread.UncaughtExceptionHandler? = null /** From 0f5d6dd03509279ba1a0856343ab9f7f1a0d067c Mon Sep 17 00:00:00 2001 From: Benoit Marty Date: Wed, 8 Feb 2023 15:23:47 +0100 Subject: [PATCH 27/51] Add test for `CrashDetectionPresenter` --- .../crash/ui/CrashDetectionPresenterTest.kt | 89 +++++++++++++++++++ .../rageshake/crash/ui/FakeCrashDataStore.kt | 48 ++++++++++ 2 files changed, 137 insertions(+) create mode 100644 features/rageshake/src/test/kotlin/io/element/android/features/rageshake/crash/ui/CrashDetectionPresenterTest.kt create mode 100644 features/rageshake/src/test/kotlin/io/element/android/features/rageshake/crash/ui/FakeCrashDataStore.kt diff --git a/features/rageshake/src/test/kotlin/io/element/android/features/rageshake/crash/ui/CrashDetectionPresenterTest.kt b/features/rageshake/src/test/kotlin/io/element/android/features/rageshake/crash/ui/CrashDetectionPresenterTest.kt new file mode 100644 index 0000000000..16a03eca86 --- /dev/null +++ b/features/rageshake/src/test/kotlin/io/element/android/features/rageshake/crash/ui/CrashDetectionPresenterTest.kt @@ -0,0 +1,89 @@ +/* + * 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. + */ + +@file:OptIn(ExperimentalCoroutinesApi::class) + +package io.element.android.features.rageshake.crash.ui + +import app.cash.molecule.RecompositionClock +import app.cash.molecule.moleculeFlow +import app.cash.turbine.test +import com.google.common.truth.Truth.assertThat +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.test.runTest +import org.junit.Test + +class CrashDetectionPresenterTest { + @Test + fun `present - initial state no crash`() = runTest { + val presenter = CrashDetectionPresenter( + FakeCrashDataStore() + ) + moleculeFlow(RecompositionClock.Immediate) { + presenter.present() + }.test { + val initialState = awaitItem() + assertThat(initialState.crashDetected).isFalse() + } + } + + @Test + fun `present - initial state crash`() = runTest { + val presenter = CrashDetectionPresenter( + FakeCrashDataStore(appHasCrashed = true) + ) + moleculeFlow(RecompositionClock.Immediate) { + presenter.present() + }.test { + skipItems(1) + val initialState = awaitItem() + assertThat(initialState.crashDetected).isTrue() + + } + } + + @Test + fun `present - reset app has crashed`() = runTest { + val presenter = CrashDetectionPresenter( + FakeCrashDataStore(appHasCrashed = true) + ) + moleculeFlow(RecompositionClock.Immediate) { + presenter.present() + }.test { + skipItems(1) + val initialState = awaitItem() + assertThat(initialState.crashDetected).isTrue() + initialState.eventSink.invoke(CrashDetectionEvents.ResetAppHasCrashed) + assertThat(awaitItem().crashDetected).isFalse() + } + } + + @Test + fun `present - reset all crash data`() = runTest { + val presenter = CrashDetectionPresenter( + FakeCrashDataStore(appHasCrashed = true, crashData = A_CRASH_DATA) + ) + moleculeFlow(RecompositionClock.Immediate) { + presenter.present() + }.test { + skipItems(1) + val initialState = awaitItem() + assertThat(initialState.crashDetected).isTrue() + initialState.eventSink.invoke(CrashDetectionEvents.ResetAllCrashData) + assertThat(awaitItem().crashDetected).isFalse() + } + } +} diff --git a/features/rageshake/src/test/kotlin/io/element/android/features/rageshake/crash/ui/FakeCrashDataStore.kt b/features/rageshake/src/test/kotlin/io/element/android/features/rageshake/crash/ui/FakeCrashDataStore.kt new file mode 100644 index 0000000000..a757931d53 --- /dev/null +++ b/features/rageshake/src/test/kotlin/io/element/android/features/rageshake/crash/ui/FakeCrashDataStore.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.features.rageshake.crash.ui + +import io.element.android.features.rageshake.crash.CrashDataStore +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.MutableStateFlow + +const val A_CRASH_DATA = "Some crash data" + +class FakeCrashDataStore( + crashData: String = "", + appHasCrashed: Boolean = false, +) : CrashDataStore { + private val appHasCrashedFlow = MutableStateFlow(appHasCrashed) + private val crashDataFlow = MutableStateFlow(crashData) + + override fun setCrashData(crashData: String) { + crashDataFlow.value = crashData + } + + override suspend fun resetAppHasCrashed() { + appHasCrashedFlow.value = false + } + + override fun appHasCrashed(): Flow = appHasCrashedFlow + + override fun crashInfo(): Flow = crashDataFlow + + override suspend fun reset() { + appHasCrashedFlow.value = false + crashDataFlow.value = "" + } +} From 50c70d10c82ae87cb6a1ee5aed5f3c1889788233 Mon Sep 17 00:00:00 2001 From: Benoit Marty Date: Wed, 8 Feb 2023 16:01:49 +0100 Subject: [PATCH 28/51] Add test for `ActionListPresenter` --- .../actionlist/ActionListPresenterTest.kt | 176 ++++++++++++++++++ .../matrixtest/timeline/FakeMatrixTimeline.kt | 1 + 2 files changed, 177 insertions(+) create mode 100644 features/messages/src/test/kotlin/io/element/android/features/messages/actionlist/ActionListPresenterTest.kt diff --git a/features/messages/src/test/kotlin/io/element/android/features/messages/actionlist/ActionListPresenterTest.kt b/features/messages/src/test/kotlin/io/element/android/features/messages/actionlist/ActionListPresenterTest.kt new file mode 100644 index 0000000000..742463d5ed --- /dev/null +++ b/features/messages/src/test/kotlin/io/element/android/features/messages/actionlist/ActionListPresenterTest.kt @@ -0,0 +1,176 @@ +/* + * 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. + */ + +@file:OptIn(ExperimentalCoroutinesApi::class) + +package io.element.android.features.messages.actionlist + +import app.cash.molecule.RecompositionClock +import app.cash.molecule.moleculeFlow +import app.cash.turbine.test +import com.google.common.truth.Truth.assertThat +import io.element.android.features.messages.actionlist.model.TimelineItemAction +import io.element.android.features.messages.timeline.model.TimelineItem +import io.element.android.features.messages.timeline.model.TimelineItemReactions +import io.element.android.features.messages.timeline.model.content.TimelineItemContent +import io.element.android.features.messages.timeline.model.content.TimelineItemRedactedContent +import io.element.android.features.messages.timeline.model.content.TimelineItemTextContent +import io.element.android.libraries.designsystem.components.avatar.AvatarData +import io.element.android.libraries.matrixtest.room.A_MESSAGE +import io.element.android.libraries.matrixtest.timeline.AN_EVENT_ID +import io.element.android.libraries.matrixtest.timeline.A_SENDER_ID +import io.element.android.libraries.matrixtest.timeline.A_SENDER_NAME +import kotlinx.collections.immutable.persistentListOf +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.test.runTest +import org.junit.Test + +class ActionListPresenterTest { + @Test + fun `present - initial state`() = runTest { + val presenter = ActionListPresenter() + moleculeFlow(RecompositionClock.Immediate) { + presenter.present() + }.test { + val initialState = awaitItem() + assertThat(initialState.target).isEqualTo(ActionListState.Target.None) + } + } + + @Test + fun `present - compute for message from me redacted`() = runTest { + val presenter = ActionListPresenter() + moleculeFlow(RecompositionClock.Immediate) { + presenter.present() + }.test { + val initialState = awaitItem() + val messageEvent = aMessageEvent(true, TimelineItemRedactedContent) + initialState.eventSink.invoke(ActionListEvents.ComputeForMessage(messageEvent)) + // val loadingState = awaitItem() + // assertThat(loadingState.target).isEqualTo(ActionListState.Target.Loading(messageEvent)) + val successState = awaitItem() + assertThat(successState.target).isEqualTo( + ActionListState.Target.Success( + messageEvent, + persistentListOf( + ) + ) + ) + initialState.eventSink.invoke(ActionListEvents.Clear) + assertThat(awaitItem().target).isEqualTo(ActionListState.Target.None) + } + } + + @Test + fun `present - compute for message from others redacted`() = runTest { + val presenter = ActionListPresenter() + moleculeFlow(RecompositionClock.Immediate) { + presenter.present() + }.test { + val initialState = awaitItem() + val messageEvent = aMessageEvent(false, TimelineItemRedactedContent) + initialState.eventSink.invoke(ActionListEvents.ComputeForMessage(messageEvent)) + // val loadingState = awaitItem() + // assertThat(loadingState.target).isEqualTo(ActionListState.Target.Loading(messageEvent)) + val successState = awaitItem() + assertThat(successState.target).isEqualTo( + ActionListState.Target.Success( + messageEvent, + persistentListOf( + ) + ) + ) + initialState.eventSink.invoke(ActionListEvents.Clear) + assertThat(awaitItem().target).isEqualTo(ActionListState.Target.None) + } + } + + @Test + fun `present - compute for others message`() = runTest { + val presenter = ActionListPresenter() + moleculeFlow(RecompositionClock.Immediate) { + presenter.present() + }.test { + val initialState = awaitItem() + val messageEvent = aMessageEvent( + isMine = false, + content = TimelineItemTextContent(body = A_MESSAGE, htmlDocument = null) + ) + initialState.eventSink.invoke(ActionListEvents.ComputeForMessage(messageEvent)) + // val loadingState = awaitItem() + // assertThat(loadingState.target).isEqualTo(ActionListState.Target.Loading(messageEvent)) + val successState = awaitItem() + assertThat(successState.target).isEqualTo( + ActionListState.Target.Success( + messageEvent, + persistentListOf( + TimelineItemAction.Reply, + TimelineItemAction.Forward, + TimelineItemAction.Copy, + ) + ) + ) + initialState.eventSink.invoke(ActionListEvents.Clear) + assertThat(awaitItem().target).isEqualTo(ActionListState.Target.None) + } + } + + @Test + fun `present - compute for my message`() = runTest { + val presenter = ActionListPresenter() + moleculeFlow(RecompositionClock.Immediate) { + presenter.present() + }.test { + val initialState = awaitItem() + val messageEvent = aMessageEvent( + isMine = true, + content = TimelineItemTextContent(body = A_MESSAGE, htmlDocument = null) + ) + initialState.eventSink.invoke(ActionListEvents.ComputeForMessage(messageEvent)) + // val loadingState = awaitItem() + // assertThat(loadingState.target).isEqualTo(ActionListState.Target.Loading(messageEvent)) + val successState = awaitItem() + assertThat(successState.target).isEqualTo( + ActionListState.Target.Success( + messageEvent, + persistentListOf( + TimelineItemAction.Reply, + TimelineItemAction.Forward, + TimelineItemAction.Copy, + TimelineItemAction.Edit, + TimelineItemAction.Redact, + ) + ) + ) + initialState.eventSink.invoke(ActionListEvents.Clear) + assertThat(awaitItem().target).isEqualTo(ActionListState.Target.None) + } + } +} + +private fun aMessageEvent( + isMine: Boolean, + content: TimelineItemContent, +) = TimelineItem.MessageEvent( + id = AN_EVENT_ID, + senderId = A_SENDER_ID, + senderDisplayName = A_SENDER_NAME, + senderAvatar = AvatarData(), + content = content, + sentTime = "", + isMine = isMine, + reactionsState = TimelineItemReactions(persistentListOf()) +) diff --git a/libraries/matrixtest/src/main/kotlin/io/element/android/libraries/matrixtest/timeline/FakeMatrixTimeline.kt b/libraries/matrixtest/src/main/kotlin/io/element/android/libraries/matrixtest/timeline/FakeMatrixTimeline.kt index 17eb98cb50..4a24e168dc 100644 --- a/libraries/matrixtest/src/main/kotlin/io/element/android/libraries/matrixtest/timeline/FakeMatrixTimeline.kt +++ b/libraries/matrixtest/src/main/kotlin/io/element/android/libraries/matrixtest/timeline/FakeMatrixTimeline.kt @@ -25,6 +25,7 @@ import kotlinx.coroutines.flow.emptyFlow import org.matrix.rustcomponents.sdk.TimelineListener const val A_SENDER_NAME = "Alice" +const val A_SENDER_ID = "@alice:server.org" const val AN_EVENT_ID_VALUE = "!anEventId" val AN_EVENT_ID = EventId(AN_EVENT_ID_VALUE) From 2c19ee6f8e540e1adfc8db40a1a90c58d8da34f2 Mon Sep 17 00:00:00 2001 From: Benoit Marty Date: Wed, 8 Feb 2023 16:05:21 +0100 Subject: [PATCH 29/51] Improve coverage of `TemplatePresenter` --- .../features/template/TemplatePresenterTests.kt | 17 +++++++++++++---- 1 file changed, 13 insertions(+), 4 deletions(-) diff --git a/features/template/src/test/kotlin/io/element/android/features/template/TemplatePresenterTests.kt b/features/template/src/test/kotlin/io/element/android/features/template/TemplatePresenterTests.kt index 34cc73ba53..a14cd2761e 100644 --- a/features/template/src/test/kotlin/io/element/android/features/template/TemplatePresenterTests.kt +++ b/features/template/src/test/kotlin/io/element/android/features/template/TemplatePresenterTests.kt @@ -21,7 +21,7 @@ package io.element.android.features.template import app.cash.molecule.RecompositionClock import app.cash.molecule.moleculeFlow import app.cash.turbine.test -import com.google.common.truth.Truth +import com.google.common.truth.Truth.assertThat import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.test.runTest import org.junit.Test @@ -29,15 +29,24 @@ import org.junit.Test class TemplatePresenterTests { @Test - fun `present - `() = runTest { - + fun `present - initial state`() = runTest { val presenter = TemplatePresenter() moleculeFlow(RecompositionClock.Immediate) { presenter.present() }.test { val initialState = awaitItem() - Truth.assertThat(initialState) + assertThat(initialState) } + } + @Test + fun `present - send event`() = runTest { + val presenter = TemplatePresenter() + moleculeFlow(RecompositionClock.Immediate) { + presenter.present() + }.test { + val initialState = awaitItem() + initialState.eventSink.invoke(TemplateEvents.MyEvent) + } } } From a2b02fbf9d1b16ff979ab289849acb731f01bc0b Mon Sep 17 00:00:00 2001 From: Benoit Marty Date: Wed, 8 Feb 2023 16:15:12 +0100 Subject: [PATCH 30/51] Convert BugReporter to an interface for testing purpose. Cannot use `@ContributesBinding(AppScope::class)`, so provide the implementation in AppModule. --- .../io/element/android/x/di/AppModule.kt | 19 + .../rageshake/bugreport/BugReportPresenter.kt | 9 +- .../rageshake/reporter/BugReporter.kt | 507 +---------------- .../rageshake/reporter/BugReporterListener.kt | 46 ++ .../rageshake/reporter/DefaultBugReporter.kt | 520 ++++++++++++++++++ 5 files changed, 596 insertions(+), 505 deletions(-) mode change 100755 => 100644 features/rageshake/src/main/kotlin/io/element/android/features/rageshake/reporter/BugReporter.kt create mode 100644 features/rageshake/src/main/kotlin/io/element/android/features/rageshake/reporter/BugReporterListener.kt create mode 100755 features/rageshake/src/main/kotlin/io/element/android/features/rageshake/reporter/DefaultBugReporter.kt diff --git a/app/src/main/kotlin/io/element/android/x/di/AppModule.kt b/app/src/main/kotlin/io/element/android/x/di/AppModule.kt index 7cb3fb55c3..c41ae86dff 100644 --- a/app/src/main/kotlin/io/element/android/x/di/AppModule.kt +++ b/app/src/main/kotlin/io/element/android/x/di/AppModule.kt @@ -20,6 +20,10 @@ import android.content.Context import com.squareup.anvil.annotations.ContributesTo import dagger.Module import dagger.Provides +import io.element.android.features.rageshake.crash.CrashDataStore +import io.element.android.features.rageshake.reporter.BugReporter +import io.element.android.features.rageshake.reporter.DefaultBugReporter +import io.element.android.features.rageshake.screenshot.ScreenshotHolder import io.element.android.libraries.core.coroutine.CoroutineDispatchers import io.element.android.libraries.di.AppScope import io.element.android.libraries.di.ApplicationContext @@ -58,4 +62,19 @@ object AppModule { diffUpdateDispatcher = Executors.newSingleThreadExecutor().asCoroutineDispatcher() ) } + + @Provides + fun provideBugReporter( + @ApplicationContext context: Context, + screenshotHolder: ScreenshotHolder, + crashDataStore: CrashDataStore, + coroutineDispatchers: CoroutineDispatchers, + ): BugReporter { + return DefaultBugReporter( + context = context, + screenshotHolder = screenshotHolder, + crashDataStore = crashDataStore, + coroutineDispatchers = coroutineDispatchers, + ) + } } diff --git a/features/rageshake/src/main/kotlin/io/element/android/features/rageshake/bugreport/BugReportPresenter.kt b/features/rageshake/src/main/kotlin/io/element/android/features/rageshake/bugreport/BugReportPresenter.kt index cd555e375a..2b45a02941 100644 --- a/features/rageshake/src/main/kotlin/io/element/android/features/rageshake/bugreport/BugReportPresenter.kt +++ b/features/rageshake/src/main/kotlin/io/element/android/features/rageshake/bugreport/BugReportPresenter.kt @@ -27,6 +27,7 @@ import androidx.core.net.toUri import io.element.android.features.rageshake.crash.CrashDataStore import io.element.android.features.rageshake.logs.VectorFileLogger import io.element.android.features.rageshake.reporter.BugReporter +import io.element.android.features.rageshake.reporter.BugReporterListener import io.element.android.features.rageshake.reporter.ReportType import io.element.android.features.rageshake.screenshot.ScreenshotHolder import io.element.android.libraries.architecture.Async @@ -45,7 +46,7 @@ class BugReportPresenter @Inject constructor( private class BugReporterUploadListener( private val sendingProgress: MutableState, private val sendingAction: MutableState> - ) : BugReporter.IMXBugReportListener { + ) : BugReporterListener { override fun onUploadCancelled() { sendingProgress.value = 0f @@ -126,7 +127,11 @@ class BugReportPresenter @Inject constructor( formState.value = operation(formState.value) } - private fun CoroutineScope.sendBugReport(formState: BugReportFormState, hasCrashLogs: Boolean, listener: BugReporter.IMXBugReportListener) = launch { + private fun CoroutineScope.sendBugReport( + formState: BugReportFormState, + hasCrashLogs: Boolean, + listener: BugReporterListener, + ) = launch { bugReporter.sendBugReport( coroutineScope = this, reportType = ReportType.BUG_REPORT, diff --git a/features/rageshake/src/main/kotlin/io/element/android/features/rageshake/reporter/BugReporter.kt b/features/rageshake/src/main/kotlin/io/element/android/features/rageshake/reporter/BugReporter.kt old mode 100755 new mode 100644 index 6cf888a44e..3acd22107d --- a/features/rageshake/src/main/kotlin/io/element/android/features/rageshake/reporter/BugReporter.kt +++ b/features/rageshake/src/main/kotlin/io/element/android/features/rageshake/reporter/BugReporter.kt @@ -1,5 +1,5 @@ /* - * Copyright (c) 2022 New Vector Ltd + * 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. @@ -16,126 +16,9 @@ package io.element.android.features.rageshake.reporter -import android.content.Context -import android.os.Build -import io.element.android.features.rageshake.R -import io.element.android.features.rageshake.crash.CrashDataStore -import io.element.android.features.rageshake.logs.VectorFileLogger -import io.element.android.features.rageshake.screenshot.ScreenshotHolder -import io.element.android.libraries.androidutils.file.compressFile -import io.element.android.libraries.androidutils.file.safeDelete -import io.element.android.libraries.core.coroutine.CoroutineDispatchers -import io.element.android.libraries.core.extensions.toOnOff -import io.element.android.libraries.core.mimetype.MimeTypes -import io.element.android.libraries.di.ApplicationContext import kotlinx.coroutines.CoroutineScope -import kotlinx.coroutines.flow.first -import kotlinx.coroutines.launch -import kotlinx.coroutines.withContext -import okhttp3.Call -import okhttp3.MediaType.Companion.toMediaTypeOrNull -import okhttp3.OkHttpClient -import okhttp3.Request -import okhttp3.RequestBody.Companion.asRequestBody -import okhttp3.Response -import org.json.JSONException -import org.json.JSONObject -import timber.log.Timber -import java.io.File -import java.io.IOException -import java.io.OutputStreamWriter -import java.net.HttpURLConnection -import java.util.Locale -import javax.inject.Inject - -/** - * BugReporter creates and sends the bug reports. - */ -class BugReporter @Inject constructor( - @ApplicationContext private val context: Context, - private val screenshotHolder: ScreenshotHolder, - private val crashDataStore: CrashDataStore, - private val coroutineDispatchers: CoroutineDispatchers, - /* - private val activeSessionHolder: ActiveSessionHolder, - private val versionProvider: VersionProvider, - private val vectorPreferences: VectorPreferences, - private val vectorFileLogger: VectorFileLogger, - private val systemLocaleProvider: SystemLocaleProvider, - private val matrix: Matrix, - private val buildMeta: BuildMeta, - private val processInfo: ProcessInfo, - private val sdkIntProvider: BuildVersionSdkIntProvider, - private val vectorLocale: VectorLocaleProvider, - */ -) { - var inMultiWindowMode = false - - companion object { - // filenames - private const val LOG_CAT_ERROR_FILENAME = "logcatError.log" - private const val LOG_CAT_FILENAME = "logcat.log" - private const val KEY_REQUESTS_FILENAME = "keyRequests.log" - - private const val BUFFER_SIZE = 1024 * 1024 * 50 - } - - // the http client - private val mOkHttpClient = OkHttpClient() - - // the pending bug report call - private var mBugReportCall: Call? = null - - // boolean to cancel the bug report - private val mIsCancelled = false - - /* - val adapter = MatrixJsonParser.getMoshi() - .adapter(Types.newParameterizedType(Map::class.java, String::class.java, Any::class.java)) - */ - - private val LOGCAT_CMD_ERROR = arrayOf( - "logcat", // /< Run 'logcat' command - "-d", // /< Dump the log rather than continue outputting it - "-v", // formatting - "threadtime", // include timestamps - "AndroidRuntime:E " + // /< Pick all AndroidRuntime errors (such as uncaught exceptions)"communicatorjni:V " + ///< All communicatorjni logging - "libcommunicator:V " + // /< All libcommunicator logging - "DEBUG:V " + // /< All DEBUG logging - which includes native land crashes (seg faults, etc) - "*:S" // /< Everything else silent, so don't pick it.. - ) - - private val LOGCAT_CMD_DEBUG = arrayOf("logcat", "-d", "-v", "threadtime", "*:*") - - /** - * Bug report upload listener. - */ - interface IMXBugReportListener { - /** - * The bug report has been cancelled. - */ - fun onUploadCancelled() - - /** - * The bug report upload failed. - * - * @param reason the failure reason - */ - fun onUploadFailed(reason: String?) - - /** - * The upload progress (in percent). - * - * @param progress the upload progress - */ - fun onProgress(progress: Int) - - /** - * The bug report upload succeeded. - */ - fun onUploadSucceed(reportUrl: String?) - } +interface BugReporter { /** * Send a bug report. * @@ -162,388 +45,6 @@ class BugReporter @Inject constructor( serverVersion: String, canContact: Boolean = false, customFields: Map? = null, - listener: IMXBugReportListener? - ) { - // enumerate files to delete - val mBugReportFiles: MutableList = ArrayList() - - coroutineScope.launch { - var serverError: String? = null - var reportURL: String? = null - withContext(coroutineDispatchers.io) { - var bugDescription = theBugDescription - val crashCallStack = crashDataStore.crashInfo().first() - - if (crashCallStack.isNotEmpty() && withCrashLogs) { - bugDescription += "\n\n\n\n--------------------------------- crash call stack ---------------------------------\n" - bugDescription += crashCallStack - } - - val gzippedFiles = ArrayList() - - val vectorFileLogger = VectorFileLogger.getFromTimber() - if (withDevicesLogs) { - val files = vectorFileLogger.getLogFiles() - files.mapNotNullTo(gzippedFiles) { f -> - if (!mIsCancelled) { - compressFile(f) - } else { - null - } - } - } - - if (!mIsCancelled && (withCrashLogs || withDevicesLogs)) { - val gzippedLogcat = saveLogCat(false) - - if (null != gzippedLogcat) { - if (gzippedFiles.size == 0) { - gzippedFiles.add(gzippedLogcat) - } else { - gzippedFiles.add(0, gzippedLogcat) - } - } - } - - /* - activeSessionHolder.getSafeActiveSession() - ?.takeIf { !mIsCancelled && withKeyRequestHistory } - ?.cryptoService() - ?.getGossipingEvents() - ?.let { GossipingEventsSerializer().serialize(it) } - ?.toByteArray() - ?.let { rawByteArray -> - File(context.cacheDir.absolutePath, KEY_REQUESTS_FILENAME) - .also { - it.outputStream() - .use { os -> os.write(rawByteArray) } - } - } - ?.let { compressFile(it) } - ?.let { gzippedFiles.add(it) } - */ - - var deviceId = "undefined" - var userId = "undefined" - var olmVersion = "undefined" - - /* - activeSessionHolder.getSafeActiveSession()?.let { session -> - userId = session.myUserId - deviceId = session.sessionParams.deviceId ?: "undefined" - olmVersion = session.cryptoService().getCryptoVersion(context, true) - } - */ - - if (!mIsCancelled) { - val text = when (reportType) { - ReportType.BUG_REPORT -> bugDescription - ReportType.SUGGESTION -> "[Suggestion] $bugDescription" - ReportType.SPACE_BETA_FEEDBACK -> "[spaces-feedback] $bugDescription" - ReportType.THREADS_BETA_FEEDBACK -> "[threads-feedback] $bugDescription" - ReportType.AUTO_UISI_SENDER, - ReportType.AUTO_UISI -> bugDescription - } - - // build the multi part request - val builder = BugReporterMultipartBody.Builder() - .addFormDataPart("text", text) - .addFormDataPart("app", rageShakeAppNameForReport(reportType)) - // .addFormDataPart("user_agent", matrix.getUserAgent()) - .addFormDataPart("user_id", userId) - .addFormDataPart("can_contact", canContact.toString()) - .addFormDataPart("device_id", deviceId) - // .addFormDataPart("version", versionProvider.getVersion(longFormat = true)) - // .addFormDataPart("branch_name", buildMeta.gitBranchName) - // .addFormDataPart("matrix_sdk_version", Matrix.getSdkVersion()) - .addFormDataPart("olm_version", olmVersion) - .addFormDataPart("device", Build.MODEL.trim()) - // .addFormDataPart("verbose_log", vectorPreferences.labAllowedExtendedLogging().toOnOff()) - .addFormDataPart("multi_window", inMultiWindowMode.toOnOff()) - // .addFormDataPart( - // "os", Build.VERSION.RELEASE + " (API " + sdkIntProvider.get() + ") " + - // Build.VERSION.INCREMENTAL + "-" + Build.VERSION.CODENAME - // ) - .addFormDataPart("locale", Locale.getDefault().toString()) - // .addFormDataPart("app_language", vectorLocale.applicationLocale.toString()) - // .addFormDataPart("default_app_language", systemLocaleProvider.getSystemLocale().toString()) - // .addFormDataPart("theme", ThemeUtils.getApplicationTheme(context)) - .addFormDataPart("server_version", serverVersion) - .apply { - customFields?.forEach { (name, value) -> - addFormDataPart(name, value) - } - } - - // add the gzipped files - for (file in gzippedFiles) { - builder.addFormDataPart("compressed-log", file.name, file.asRequestBody(MimeTypes.OctetStream.toMediaTypeOrNull())) - } - - mBugReportFiles.addAll(gzippedFiles) - - if (withScreenshot) { - screenshotHolder.getFile()?.let { screenshotFile -> - try { - builder.addFormDataPart( - "file", - screenshotFile.name, screenshotFile.asRequestBody(MimeTypes.OctetStream.toMediaTypeOrNull()) - ) - } catch (e: Exception) { - Timber.e(e, "## sendBugReport() : fail to write screenshot") - } - } - } - - // add some github labels - // builder.addFormDataPart("label", buildMeta.versionName) - // builder.addFormDataPart("label", buildMeta.flavorDescription) - // builder.addFormDataPart("label", buildMeta.gitBranchName) - - // Possible values for BuildConfig.BUILD_TYPE: "debug", "nightly", "release". - // builder.addFormDataPart("label", BuildConfig.BUILD_TYPE) - - when (reportType) { - ReportType.BUG_REPORT -> { - /* nop */ - } - ReportType.SUGGESTION -> builder.addFormDataPart("label", "[Suggestion]") - ReportType.SPACE_BETA_FEEDBACK -> builder.addFormDataPart("label", "spaces-feedback") - ReportType.THREADS_BETA_FEEDBACK -> builder.addFormDataPart("label", "threads-feedback") - ReportType.AUTO_UISI -> { - builder.addFormDataPart("label", "Z-UISI") - builder.addFormDataPart("label", "android") - builder.addFormDataPart("label", "uisi-recipient") - } - ReportType.AUTO_UISI_SENDER -> { - builder.addFormDataPart("label", "Z-UISI") - builder.addFormDataPart("label", "android") - builder.addFormDataPart("label", "uisi-sender") - } - } - - if (crashCallStack.isNotEmpty() && withCrashLogs) { - builder.addFormDataPart("label", "crash") - } - - val requestBody = builder.build() - - // add a progress listener - requestBody.setWriteListener { totalWritten, contentLength -> - val percentage = if (-1L != contentLength) { - if (totalWritten > contentLength) { - 100 - } else { - (totalWritten * 100 / contentLength).toInt() - } - } else { - 0 - } - - if (mIsCancelled && null != mBugReportCall) { - mBugReportCall!!.cancel() - } - - Timber.v("## onWrite() : $percentage%") - try { - listener?.onProgress(percentage) - } catch (e: Exception) { - Timber.e(e, "## onProgress() : failed") - } - } - - // build the request - val request = Request.Builder() - .url(context.getString(R.string.bug_report_url)) - .post(requestBody) - .build() - - var responseCode = HttpURLConnection.HTTP_INTERNAL_ERROR - var response: Response? = null - var errorMessage: String? = null - - // trigger the request - try { - mBugReportCall = mOkHttpClient.newCall(request) - response = mBugReportCall!!.execute() - responseCode = response.code - } catch (e: Exception) { - Timber.e(e, "response") - errorMessage = e.localizedMessage - } - - // if the upload failed, try to retrieve the reason - if (responseCode != HttpURLConnection.HTTP_OK) { - if (null != errorMessage) { - serverError = "Failed with error $errorMessage" - } else if (response?.body == null) { - serverError = "Failed with error $responseCode" - } else { - try { - val inputStream = response.body!!.byteStream() - - serverError = inputStream.use { - buildString { - var ch = it.read() - while (ch != -1) { - append(ch.toChar()) - ch = it.read() - } - } - } - - // check if the error message - serverError?.let { - try { - val responseJSON = JSONObject(it) - serverError = responseJSON.getString("error") - } catch (e: JSONException) { - Timber.e(e, "doInBackground ; Json conversion failed") - } - } - - // should never happen - if (null == serverError) { - serverError = "Failed with error $responseCode" - } - } catch (e: Exception) { - Timber.e(e, "## sendBugReport() : failed to parse error") - } - } - } else { - /* - reportURL = response?.body?.string()?.let { stringBody -> - adapter.fromJson(stringBody)?.get("report_url")?.toString() - } - */ - } - } - } - - withContext(coroutineDispatchers.main) { - mBugReportCall = null - - // delete when the bug report has been successfully sent - for (file in mBugReportFiles) { - file.safeDelete() - } - - if (null != listener) { - try { - if (mIsCancelled) { - listener.onUploadCancelled() - } else if (null == serverError) { - listener.onUploadSucceed(reportURL) - } else { - listener.onUploadFailed(serverError) - } - } catch (e: Exception) { - Timber.e(e, "## onPostExecute() : failed") - } - } - } - } - } - - /** - * Send a bug report either with email or with Vector. - */ - /* TODO Remove - fun openBugReportScreen(activity: FragmentActivity, reportType: ReportType = ReportType.BUG_REPORT) { - screenshot = takeScreenshot(activity) - logDbInfo() - logProcessInfo() - logOtherInfo() - activity.startActivity(BugReportActivity.intent(activity, reportType)) - } - */ - - // private fun logOtherInfo() { - // Timber.i("SyncThread state: " + activeSessionHolder.getSafeActiveSession()?.syncService()?.getSyncState()) - // } - - // private fun logDbInfo() { - // val dbInfo = matrix.debugService().getDbUsageInfo() - // Timber.i(dbInfo) - // } - - // private fun logProcessInfo() { - // val pInfo = processInfo.getInfo() - // Timber.i(pInfo) - // } - - private fun rageShakeAppNameForReport(reportType: ReportType): String { - // As per https://github.com/matrix-org/rageshake - // app: Identifier for the application (eg 'riot-web'). - // Should correspond to a mapping configured in the configuration file for github issue reporting to work. - // (see R.string.bug_report_url for configured RS server) - return context.getString( - when (reportType) { - ReportType.AUTO_UISI_SENDER, - ReportType.AUTO_UISI -> R.string.bug_report_auto_uisi_app_name - else -> R.string.bug_report_app_name - } - ) - } - - // ============================================================================================================== - // Logcat management - // ============================================================================================================== - - /** - * Save the logcat. - * - * @param isErrorLogcat true to save the error logcat - * @return the file if the operation succeeds - */ - private fun saveLogCat(isErrorLogcat: Boolean): File? { - val logCatErrFile = File(context.cacheDir.absolutePath, if (isErrorLogcat) LOG_CAT_ERROR_FILENAME else LOG_CAT_FILENAME) - - if (logCatErrFile.exists()) { - logCatErrFile.safeDelete() - } - - try { - logCatErrFile.writer().use { - getLogCatError(it, isErrorLogcat) - } - - return compressFile(logCatErrFile) - } catch (error: OutOfMemoryError) { - Timber.e(error, "## saveLogCat() : fail to write logcat$error") - } catch (e: Exception) { - Timber.e(e, "## saveLogCat() : fail to write logcat$e") - } - - return null - } - - /** - * Retrieves the logs. - * - * @param streamWriter the stream writer - * @param isErrorLogCat true to save the error logs - */ - private fun getLogCatError(streamWriter: OutputStreamWriter, isErrorLogCat: Boolean) { - val logcatProc: Process - - try { - logcatProc = Runtime.getRuntime().exec(if (isErrorLogCat) LOGCAT_CMD_ERROR else LOGCAT_CMD_DEBUG) - } catch (e1: IOException) { - return - } - - try { - val separator = System.getProperty("line.separator") - logcatProc.inputStream - .reader() - .buffered(BUFFER_SIZE) - .forEachLine { line -> - streamWriter.append(line) - streamWriter.append(separator) - } - } catch (e: IOException) { - Timber.e(e, "getLog fails") - } - } + listener: BugReporterListener? + ) } diff --git a/features/rageshake/src/main/kotlin/io/element/android/features/rageshake/reporter/BugReporterListener.kt b/features/rageshake/src/main/kotlin/io/element/android/features/rageshake/reporter/BugReporterListener.kt new file mode 100644 index 0000000000..3259034ad7 --- /dev/null +++ b/features/rageshake/src/main/kotlin/io/element/android/features/rageshake/reporter/BugReporterListener.kt @@ -0,0 +1,46 @@ +/* + * 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.features.rageshake.reporter + +/** + * Bug report upload listener. + */ +interface BugReporterListener { + /** + * The bug report has been cancelled. + */ + fun onUploadCancelled() + + /** + * The bug report upload failed. + * + * @param reason the failure reason + */ + fun onUploadFailed(reason: String?) + + /** + * The upload progress (in percent). + * + * @param progress the upload progress + */ + fun onProgress(progress: Int) + + /** + * The bug report upload succeeded. + */ + fun onUploadSucceed(reportUrl: String?) +} diff --git a/features/rageshake/src/main/kotlin/io/element/android/features/rageshake/reporter/DefaultBugReporter.kt b/features/rageshake/src/main/kotlin/io/element/android/features/rageshake/reporter/DefaultBugReporter.kt new file mode 100755 index 0000000000..bb90206a92 --- /dev/null +++ b/features/rageshake/src/main/kotlin/io/element/android/features/rageshake/reporter/DefaultBugReporter.kt @@ -0,0 +1,520 @@ +/* + * Copyright (c) 2022 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.features.rageshake.reporter + +import android.content.Context +import android.os.Build +import io.element.android.features.rageshake.R +import io.element.android.features.rageshake.crash.CrashDataStore +import io.element.android.features.rageshake.logs.VectorFileLogger +import io.element.android.features.rageshake.screenshot.ScreenshotHolder +import io.element.android.libraries.androidutils.file.compressFile +import io.element.android.libraries.androidutils.file.safeDelete +import io.element.android.libraries.core.coroutine.CoroutineDispatchers +import io.element.android.libraries.core.extensions.toOnOff +import io.element.android.libraries.core.mimetype.MimeTypes +import io.element.android.libraries.di.ApplicationContext +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext +import okhttp3.Call +import okhttp3.MediaType.Companion.toMediaTypeOrNull +import okhttp3.OkHttpClient +import okhttp3.Request +import okhttp3.RequestBody.Companion.asRequestBody +import okhttp3.Response +import org.json.JSONException +import org.json.JSONObject +import timber.log.Timber +import java.io.File +import java.io.IOException +import java.io.OutputStreamWriter +import java.net.HttpURLConnection +import java.util.Locale +import javax.inject.Inject + +/** + * BugReporter creates and sends the bug reports. + */ +class DefaultBugReporter @Inject constructor( + @ApplicationContext private val context: Context, + private val screenshotHolder: ScreenshotHolder, + private val crashDataStore: CrashDataStore, + private val coroutineDispatchers: CoroutineDispatchers, + /* + private val activeSessionHolder: ActiveSessionHolder, + private val versionProvider: VersionProvider, + private val vectorPreferences: VectorPreferences, + private val vectorFileLogger: VectorFileLogger, + private val systemLocaleProvider: SystemLocaleProvider, + private val matrix: Matrix, + private val buildMeta: BuildMeta, + private val processInfo: ProcessInfo, + private val sdkIntProvider: BuildVersionSdkIntProvider, + private val vectorLocale: VectorLocaleProvider, + */ +) : BugReporter { + var inMultiWindowMode = false + + companion object { + // filenames + private const val LOG_CAT_ERROR_FILENAME = "logcatError.log" + private const val LOG_CAT_FILENAME = "logcat.log" + private const val KEY_REQUESTS_FILENAME = "keyRequests.log" + + private const val BUFFER_SIZE = 1024 * 1024 * 50 + } + + // the http client + private val mOkHttpClient = OkHttpClient() + + // the pending bug report call + private var mBugReportCall: Call? = null + + // boolean to cancel the bug report + private val mIsCancelled = false + + /* + val adapter = MatrixJsonParser.getMoshi() + .adapter(Types.newParameterizedType(Map::class.java, String::class.java, Any::class.java)) + */ + + private val LOGCAT_CMD_ERROR = arrayOf( + "logcat", // /< Run 'logcat' command + "-d", // /< Dump the log rather than continue outputting it + "-v", // formatting + "threadtime", // include timestamps + "AndroidRuntime:E " + // /< Pick all AndroidRuntime errors (such as uncaught exceptions)"communicatorjni:V " + ///< All communicatorjni logging + "libcommunicator:V " + // /< All libcommunicator logging + "DEBUG:V " + // /< All DEBUG logging - which includes native land crashes (seg faults, etc) + "*:S" // /< Everything else silent, so don't pick it.. + ) + + private val LOGCAT_CMD_DEBUG = arrayOf("logcat", "-d", "-v", "threadtime", "*:*") + + /** + * Send a bug report. + * + * @param coroutineScope The coroutine scope + * @param reportType The report type (bug, suggestion, feedback) + * @param withDevicesLogs true to include the device log + * @param withCrashLogs true to include the crash logs + * @param withKeyRequestHistory true to include the crash logs + * @param withScreenshot true to include the screenshot + * @param theBugDescription the bug description + * @param serverVersion version of the server + * @param canContact true if the user opt in to be contacted directly + * @param customFields fields which will be sent with the report + * @param listener the listener + */ + override fun sendBugReport( + coroutineScope: CoroutineScope, + reportType: ReportType, + withDevicesLogs: Boolean, + withCrashLogs: Boolean, + withKeyRequestHistory: Boolean, + withScreenshot: Boolean, + theBugDescription: String, + serverVersion: String, + canContact: Boolean, + customFields: Map?, + listener: BugReporterListener? + ) { + // enumerate files to delete + val mBugReportFiles: MutableList = ArrayList() + + coroutineScope.launch { + var serverError: String? = null + var reportURL: String? = null + withContext(coroutineDispatchers.io) { + var bugDescription = theBugDescription + val crashCallStack = crashDataStore.crashInfo().first() + + if (crashCallStack.isNotEmpty() && withCrashLogs) { + bugDescription += "\n\n\n\n--------------------------------- crash call stack ---------------------------------\n" + bugDescription += crashCallStack + } + + val gzippedFiles = ArrayList() + + val vectorFileLogger = VectorFileLogger.getFromTimber() + if (withDevicesLogs) { + val files = vectorFileLogger.getLogFiles() + files.mapNotNullTo(gzippedFiles) { f -> + if (!mIsCancelled) { + compressFile(f) + } else { + null + } + } + } + + if (!mIsCancelled && (withCrashLogs || withDevicesLogs)) { + val gzippedLogcat = saveLogCat(false) + + if (null != gzippedLogcat) { + if (gzippedFiles.size == 0) { + gzippedFiles.add(gzippedLogcat) + } else { + gzippedFiles.add(0, gzippedLogcat) + } + } + } + + /* + activeSessionHolder.getSafeActiveSession() + ?.takeIf { !mIsCancelled && withKeyRequestHistory } + ?.cryptoService() + ?.getGossipingEvents() + ?.let { GossipingEventsSerializer().serialize(it) } + ?.toByteArray() + ?.let { rawByteArray -> + File(context.cacheDir.absolutePath, KEY_REQUESTS_FILENAME) + .also { + it.outputStream() + .use { os -> os.write(rawByteArray) } + } + } + ?.let { compressFile(it) } + ?.let { gzippedFiles.add(it) } + */ + + var deviceId = "undefined" + var userId = "undefined" + var olmVersion = "undefined" + + /* + activeSessionHolder.getSafeActiveSession()?.let { session -> + userId = session.myUserId + deviceId = session.sessionParams.deviceId ?: "undefined" + olmVersion = session.cryptoService().getCryptoVersion(context, true) + } + */ + + if (!mIsCancelled) { + val text = when (reportType) { + ReportType.BUG_REPORT -> bugDescription + ReportType.SUGGESTION -> "[Suggestion] $bugDescription" + ReportType.SPACE_BETA_FEEDBACK -> "[spaces-feedback] $bugDescription" + ReportType.THREADS_BETA_FEEDBACK -> "[threads-feedback] $bugDescription" + ReportType.AUTO_UISI_SENDER, + ReportType.AUTO_UISI -> bugDescription + } + + // build the multi part request + val builder = BugReporterMultipartBody.Builder() + .addFormDataPart("text", text) + .addFormDataPart("app", rageShakeAppNameForReport(reportType)) + // .addFormDataPart("user_agent", matrix.getUserAgent()) + .addFormDataPart("user_id", userId) + .addFormDataPart("can_contact", canContact.toString()) + .addFormDataPart("device_id", deviceId) + // .addFormDataPart("version", versionProvider.getVersion(longFormat = true)) + // .addFormDataPart("branch_name", buildMeta.gitBranchName) + // .addFormDataPart("matrix_sdk_version", Matrix.getSdkVersion()) + .addFormDataPart("olm_version", olmVersion) + .addFormDataPart("device", Build.MODEL.trim()) + // .addFormDataPart("verbose_log", vectorPreferences.labAllowedExtendedLogging().toOnOff()) + .addFormDataPart("multi_window", inMultiWindowMode.toOnOff()) + // .addFormDataPart( + // "os", Build.VERSION.RELEASE + " (API " + sdkIntProvider.get() + ") " + + // Build.VERSION.INCREMENTAL + "-" + Build.VERSION.CODENAME + // ) + .addFormDataPart("locale", Locale.getDefault().toString()) + // .addFormDataPart("app_language", vectorLocale.applicationLocale.toString()) + // .addFormDataPart("default_app_language", systemLocaleProvider.getSystemLocale().toString()) + // .addFormDataPart("theme", ThemeUtils.getApplicationTheme(context)) + .addFormDataPart("server_version", serverVersion) + .apply { + customFields?.forEach { (name, value) -> + addFormDataPart(name, value) + } + } + + // add the gzipped files + for (file in gzippedFiles) { + builder.addFormDataPart("compressed-log", file.name, file.asRequestBody(MimeTypes.OctetStream.toMediaTypeOrNull())) + } + + mBugReportFiles.addAll(gzippedFiles) + + if (withScreenshot) { + screenshotHolder.getFile()?.let { screenshotFile -> + try { + builder.addFormDataPart( + "file", + screenshotFile.name, screenshotFile.asRequestBody(MimeTypes.OctetStream.toMediaTypeOrNull()) + ) + } catch (e: Exception) { + Timber.e(e, "## sendBugReport() : fail to write screenshot") + } + } + } + + // add some github labels + // builder.addFormDataPart("label", buildMeta.versionName) + // builder.addFormDataPart("label", buildMeta.flavorDescription) + // builder.addFormDataPart("label", buildMeta.gitBranchName) + + // Possible values for BuildConfig.BUILD_TYPE: "debug", "nightly", "release". + // builder.addFormDataPart("label", BuildConfig.BUILD_TYPE) + + when (reportType) { + ReportType.BUG_REPORT -> { + /* nop */ + } + ReportType.SUGGESTION -> builder.addFormDataPart("label", "[Suggestion]") + ReportType.SPACE_BETA_FEEDBACK -> builder.addFormDataPart("label", "spaces-feedback") + ReportType.THREADS_BETA_FEEDBACK -> builder.addFormDataPart("label", "threads-feedback") + ReportType.AUTO_UISI -> { + builder.addFormDataPart("label", "Z-UISI") + builder.addFormDataPart("label", "android") + builder.addFormDataPart("label", "uisi-recipient") + } + ReportType.AUTO_UISI_SENDER -> { + builder.addFormDataPart("label", "Z-UISI") + builder.addFormDataPart("label", "android") + builder.addFormDataPart("label", "uisi-sender") + } + } + + if (crashCallStack.isNotEmpty() && withCrashLogs) { + builder.addFormDataPart("label", "crash") + } + + val requestBody = builder.build() + + // add a progress listener + requestBody.setWriteListener { totalWritten, contentLength -> + val percentage = if (-1L != contentLength) { + if (totalWritten > contentLength) { + 100 + } else { + (totalWritten * 100 / contentLength).toInt() + } + } else { + 0 + } + + if (mIsCancelled && null != mBugReportCall) { + mBugReportCall!!.cancel() + } + + Timber.v("## onWrite() : $percentage%") + try { + listener?.onProgress(percentage) + } catch (e: Exception) { + Timber.e(e, "## onProgress() : failed") + } + } + + // build the request + val request = Request.Builder() + .url(context.getString(R.string.bug_report_url)) + .post(requestBody) + .build() + + var responseCode = HttpURLConnection.HTTP_INTERNAL_ERROR + var response: Response? = null + var errorMessage: String? = null + + // trigger the request + try { + mBugReportCall = mOkHttpClient.newCall(request) + response = mBugReportCall!!.execute() + responseCode = response.code + } catch (e: Exception) { + Timber.e(e, "response") + errorMessage = e.localizedMessage + } + + // if the upload failed, try to retrieve the reason + if (responseCode != HttpURLConnection.HTTP_OK) { + if (null != errorMessage) { + serverError = "Failed with error $errorMessage" + } else if (response?.body == null) { + serverError = "Failed with error $responseCode" + } else { + try { + val inputStream = response.body!!.byteStream() + + serverError = inputStream.use { + buildString { + var ch = it.read() + while (ch != -1) { + append(ch.toChar()) + ch = it.read() + } + } + } + + // check if the error message + serverError?.let { + try { + val responseJSON = JSONObject(it) + serverError = responseJSON.getString("error") + } catch (e: JSONException) { + Timber.e(e, "doInBackground ; Json conversion failed") + } + } + + // should never happen + if (null == serverError) { + serverError = "Failed with error $responseCode" + } + } catch (e: Exception) { + Timber.e(e, "## sendBugReport() : failed to parse error") + } + } + } else { + /* + reportURL = response?.body?.string()?.let { stringBody -> + adapter.fromJson(stringBody)?.get("report_url")?.toString() + } + */ + } + } + } + + withContext(coroutineDispatchers.main) { + mBugReportCall = null + + // delete when the bug report has been successfully sent + for (file in mBugReportFiles) { + file.safeDelete() + } + + if (null != listener) { + try { + if (mIsCancelled) { + listener.onUploadCancelled() + } else if (null == serverError) { + listener.onUploadSucceed(reportURL) + } else { + listener.onUploadFailed(serverError) + } + } catch (e: Exception) { + Timber.e(e, "## onPostExecute() : failed") + } + } + } + } + } + + /** + * Send a bug report either with email or with Vector. + */ + /* TODO Remove + fun openBugReportScreen(activity: FragmentActivity, reportType: ReportType = ReportType.BUG_REPORT) { + screenshot = takeScreenshot(activity) + logDbInfo() + logProcessInfo() + logOtherInfo() + activity.startActivity(BugReportActivity.intent(activity, reportType)) + } + */ + + // private fun logOtherInfo() { + // Timber.i("SyncThread state: " + activeSessionHolder.getSafeActiveSession()?.syncService()?.getSyncState()) + // } + + // private fun logDbInfo() { + // val dbInfo = matrix.debugService().getDbUsageInfo() + // Timber.i(dbInfo) + // } + + // private fun logProcessInfo() { + // val pInfo = processInfo.getInfo() + // Timber.i(pInfo) + // } + + private fun rageShakeAppNameForReport(reportType: ReportType): String { + // As per https://github.com/matrix-org/rageshake + // app: Identifier for the application (eg 'riot-web'). + // Should correspond to a mapping configured in the configuration file for github issue reporting to work. + // (see R.string.bug_report_url for configured RS server) + return context.getString( + when (reportType) { + ReportType.AUTO_UISI_SENDER, + ReportType.AUTO_UISI -> R.string.bug_report_auto_uisi_app_name + else -> R.string.bug_report_app_name + } + ) + } + + // ============================================================================================================== + // Logcat management + // ============================================================================================================== + + /** + * Save the logcat. + * + * @param isErrorLogcat true to save the error logcat + * @return the file if the operation succeeds + */ + private fun saveLogCat(isErrorLogcat: Boolean): File? { + val logCatErrFile = File(context.cacheDir.absolutePath, if (isErrorLogcat) LOG_CAT_ERROR_FILENAME else LOG_CAT_FILENAME) + + if (logCatErrFile.exists()) { + logCatErrFile.safeDelete() + } + + try { + logCatErrFile.writer().use { + getLogCatError(it, isErrorLogcat) + } + + return compressFile(logCatErrFile) + } catch (error: OutOfMemoryError) { + Timber.e(error, "## saveLogCat() : fail to write logcat$error") + } catch (e: Exception) { + Timber.e(e, "## saveLogCat() : fail to write logcat$e") + } + + return null + } + + /** + * Retrieves the logs. + * + * @param streamWriter the stream writer + * @param isErrorLogCat true to save the error logs + */ + private fun getLogCatError(streamWriter: OutputStreamWriter, isErrorLogCat: Boolean) { + val logcatProc: Process + + try { + logcatProc = Runtime.getRuntime().exec(if (isErrorLogCat) LOGCAT_CMD_ERROR else LOGCAT_CMD_DEBUG) + } catch (e1: IOException) { + return + } + + try { + val separator = System.getProperty("line.separator") + logcatProc.inputStream + .reader() + .buffered(BUFFER_SIZE) + .forEachLine { line -> + streamWriter.append(line) + streamWriter.append(separator) + } + } catch (e: IOException) { + Timber.e(e, "getLog fails") + } + } +} From fa37224109d0e691a373dbc56ad46e7dd4e4ce8e Mon Sep 17 00:00:00 2001 From: Benoit Marty Date: Wed, 8 Feb 2023 17:25:05 +0100 Subject: [PATCH 31/51] Convert ScreenshotHolder to an interface for testing purpose. --- .../screenshot/DefaultScreenshotHolder.kt | 46 +++++++++++++++++++ .../rageshake/screenshot/ScreenshotHolder.kt | 28 ++--------- 2 files changed, 51 insertions(+), 23 deletions(-) create mode 100644 features/rageshake/src/main/kotlin/io/element/android/features/rageshake/screenshot/DefaultScreenshotHolder.kt diff --git a/features/rageshake/src/main/kotlin/io/element/android/features/rageshake/screenshot/DefaultScreenshotHolder.kt b/features/rageshake/src/main/kotlin/io/element/android/features/rageshake/screenshot/DefaultScreenshotHolder.kt new file mode 100644 index 0000000000..53b6291bdb --- /dev/null +++ b/features/rageshake/src/main/kotlin/io/element/android/features/rageshake/screenshot/DefaultScreenshotHolder.kt @@ -0,0 +1,46 @@ +/* + * Copyright (c) 2022 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.features.rageshake.screenshot + +import android.content.Context +import android.graphics.Bitmap +import com.squareup.anvil.annotations.ContributesBinding +import io.element.android.libraries.androidutils.bitmap.writeBitmap +import io.element.android.libraries.androidutils.file.safeDelete +import io.element.android.libraries.di.AppScope +import io.element.android.libraries.di.ApplicationContext +import io.element.android.libraries.di.SingleIn +import java.io.File +import javax.inject.Inject + +@SingleIn(AppScope::class) +@ContributesBinding(AppScope::class) +class DefaultScreenshotHolder @Inject constructor( + @ApplicationContext private val context: Context, +) : ScreenshotHolder { + private val file = File(context.filesDir, "screenshot.png") + + override fun writeBitmap(data: Bitmap) { + file.writeBitmap(data, Bitmap.CompressFormat.PNG, 85) + } + + override fun getFile() = file.takeIf { it.exists() && it.length() > 0 } + + override fun reset() { + file.safeDelete() + } +} diff --git a/features/rageshake/src/main/kotlin/io/element/android/features/rageshake/screenshot/ScreenshotHolder.kt b/features/rageshake/src/main/kotlin/io/element/android/features/rageshake/screenshot/ScreenshotHolder.kt index 33674c07fb..c570f0665d 100644 --- a/features/rageshake/src/main/kotlin/io/element/android/features/rageshake/screenshot/ScreenshotHolder.kt +++ b/features/rageshake/src/main/kotlin/io/element/android/features/rageshake/screenshot/ScreenshotHolder.kt @@ -1,5 +1,5 @@ /* - * Copyright (c) 2022 New Vector Ltd + * 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. @@ -16,29 +16,11 @@ package io.element.android.features.rageshake.screenshot -import android.content.Context import android.graphics.Bitmap -import io.element.android.libraries.androidutils.bitmap.writeBitmap -import io.element.android.libraries.androidutils.file.safeDelete -import io.element.android.libraries.di.AppScope -import io.element.android.libraries.di.ApplicationContext -import io.element.android.libraries.di.SingleIn import java.io.File -import javax.inject.Inject -@SingleIn(AppScope::class) -class ScreenshotHolder @Inject constructor( - @ApplicationContext private val context: Context, -) { - private val file = File(context.filesDir, "screenshot.png") - - fun writeBitmap(data: Bitmap) { - file.writeBitmap(data, Bitmap.CompressFormat.PNG, 85) - } - - fun getFile() = file.takeIf { it.exists() && it.length() > 0 } - - fun reset() { - file.safeDelete() - } +interface ScreenshotHolder { + fun writeBitmap(data: Bitmap) + fun getFile(): File? + fun reset() } From 4b7f64dcff129ac2a63378ce559c8c28b57213e1 Mon Sep 17 00:00:00 2001 From: Benoit Marty Date: Wed, 8 Feb 2023 18:42:17 +0100 Subject: [PATCH 32/51] Add test for `BugReportPresenter` --- .../rageshake/bugreport/BugReportPresenter.kt | 5 +- .../rageshake/logs/VectorFileLogger.kt | 4 +- .../rageshake/reporter/DefaultBugReporter.kt | 9 +- .../screenshot/DefaultScreenshotHolder.kt | 8 +- .../rageshake/screenshot/ScreenshotHolder.kt | 3 +- .../bugreport/BugReportPresenterTest.kt | 247 ++++++++++++++++++ .../rageshake/bugreport/FakeBugReporter.kt | 70 +++++ .../bugreport/FakeScreenshotHolder.kt | 30 +++ 8 files changed, 366 insertions(+), 10 deletions(-) create mode 100644 features/rageshake/src/test/kotlin/io/element/android/features/rageshake/bugreport/BugReportPresenterTest.kt create mode 100644 features/rageshake/src/test/kotlin/io/element/android/features/rageshake/bugreport/FakeBugReporter.kt create mode 100644 features/rageshake/src/test/kotlin/io/element/android/features/rageshake/bugreport/FakeScreenshotHolder.kt diff --git a/features/rageshake/src/main/kotlin/io/element/android/features/rageshake/bugreport/BugReportPresenter.kt b/features/rageshake/src/main/kotlin/io/element/android/features/rageshake/bugreport/BugReportPresenter.kt index 2b45a02941..a2e17d737b 100644 --- a/features/rageshake/src/main/kotlin/io/element/android/features/rageshake/bugreport/BugReportPresenter.kt +++ b/features/rageshake/src/main/kotlin/io/element/android/features/rageshake/bugreport/BugReportPresenter.kt @@ -23,7 +23,6 @@ import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.saveable.rememberSaveable -import androidx.core.net.toUri import io.element.android.features.rageshake.crash.CrashDataStore import io.element.android.features.rageshake.logs.VectorFileLogger import io.element.android.features.rageshake.reporter.BugReporter @@ -73,7 +72,7 @@ class BugReportPresenter @Inject constructor( override fun present(): BugReportState { val screenshotUri = rememberSaveable { mutableStateOf( - screenshotHolder.getFile()?.toUri()?.toString() + screenshotHolder.getFileUri() ) } val crashInfo: String by crashDataStore @@ -150,6 +149,6 @@ class BugReportPresenter @Inject constructor( private fun CoroutineScope.resetAll() = launch { screenshotHolder.reset() crashDataStore.reset() - VectorFileLogger.getFromTimber().reset() + VectorFileLogger.getFromTimber()?.reset() } } diff --git a/features/rageshake/src/main/kotlin/io/element/android/features/rageshake/logs/VectorFileLogger.kt b/features/rageshake/src/main/kotlin/io/element/android/features/rageshake/logs/VectorFileLogger.kt index 1d8d0b349b..5df72e29f7 100644 --- a/features/rageshake/src/main/kotlin/io/element/android/features/rageshake/logs/VectorFileLogger.kt +++ b/features/rageshake/src/main/kotlin/io/element/android/features/rageshake/logs/VectorFileLogger.kt @@ -43,8 +43,8 @@ class VectorFileLogger( ) : Timber.Tree() { companion object { - fun getFromTimber(): VectorFileLogger { - return Timber.forest().filterIsInstance().first() + fun getFromTimber(): VectorFileLogger? { + return Timber.forest().filterIsInstance().firstOrNull() } private const val SIZE_20MB = 20 * 1024 * 1024 diff --git a/features/rageshake/src/main/kotlin/io/element/android/features/rageshake/reporter/DefaultBugReporter.kt b/features/rageshake/src/main/kotlin/io/element/android/features/rageshake/reporter/DefaultBugReporter.kt index bb90206a92..cfc0c68561 100755 --- a/features/rageshake/src/main/kotlin/io/element/android/features/rageshake/reporter/DefaultBugReporter.kt +++ b/features/rageshake/src/main/kotlin/io/element/android/features/rageshake/reporter/DefaultBugReporter.kt @@ -18,6 +18,8 @@ package io.element.android.features.rageshake.reporter import android.content.Context import android.os.Build +import androidx.core.net.toFile +import androidx.core.net.toUri import io.element.android.features.rageshake.R import io.element.android.features.rageshake.crash.CrashDataStore import io.element.android.features.rageshake.logs.VectorFileLogger @@ -153,7 +155,7 @@ class DefaultBugReporter @Inject constructor( val gzippedFiles = ArrayList() val vectorFileLogger = VectorFileLogger.getFromTimber() - if (withDevicesLogs) { + if (withDevicesLogs && vectorFileLogger != null) { val files = vectorFileLogger.getLogFiles() files.mapNotNullTo(gzippedFiles) { f -> if (!mIsCancelled) { @@ -254,7 +256,10 @@ class DefaultBugReporter @Inject constructor( mBugReportFiles.addAll(gzippedFiles) if (withScreenshot) { - screenshotHolder.getFile()?.let { screenshotFile -> + screenshotHolder.getFileUri() + ?.toUri() + ?.toFile() + ?.let { screenshotFile -> try { builder.addFormDataPart( "file", diff --git a/features/rageshake/src/main/kotlin/io/element/android/features/rageshake/screenshot/DefaultScreenshotHolder.kt b/features/rageshake/src/main/kotlin/io/element/android/features/rageshake/screenshot/DefaultScreenshotHolder.kt index 53b6291bdb..31eeac39a6 100644 --- a/features/rageshake/src/main/kotlin/io/element/android/features/rageshake/screenshot/DefaultScreenshotHolder.kt +++ b/features/rageshake/src/main/kotlin/io/element/android/features/rageshake/screenshot/DefaultScreenshotHolder.kt @@ -18,6 +18,7 @@ package io.element.android.features.rageshake.screenshot import android.content.Context import android.graphics.Bitmap +import androidx.core.net.toUri import com.squareup.anvil.annotations.ContributesBinding import io.element.android.libraries.androidutils.bitmap.writeBitmap import io.element.android.libraries.androidutils.file.safeDelete @@ -38,7 +39,12 @@ class DefaultScreenshotHolder @Inject constructor( file.writeBitmap(data, Bitmap.CompressFormat.PNG, 85) } - override fun getFile() = file.takeIf { it.exists() && it.length() > 0 } + override fun getFileUri(): String? { + return file + .takeIf { it.exists() && it.length() > 0 } + ?.toUri() + ?.toString() + } override fun reset() { file.safeDelete() diff --git a/features/rageshake/src/main/kotlin/io/element/android/features/rageshake/screenshot/ScreenshotHolder.kt b/features/rageshake/src/main/kotlin/io/element/android/features/rageshake/screenshot/ScreenshotHolder.kt index c570f0665d..dfe31ae2fe 100644 --- a/features/rageshake/src/main/kotlin/io/element/android/features/rageshake/screenshot/ScreenshotHolder.kt +++ b/features/rageshake/src/main/kotlin/io/element/android/features/rageshake/screenshot/ScreenshotHolder.kt @@ -17,10 +17,9 @@ package io.element.android.features.rageshake.screenshot import android.graphics.Bitmap -import java.io.File interface ScreenshotHolder { fun writeBitmap(data: Bitmap) - fun getFile(): File? + fun getFileUri(): String? fun reset() } diff --git a/features/rageshake/src/test/kotlin/io/element/android/features/rageshake/bugreport/BugReportPresenterTest.kt b/features/rageshake/src/test/kotlin/io/element/android/features/rageshake/bugreport/BugReportPresenterTest.kt new file mode 100644 index 0000000000..8737415d6c --- /dev/null +++ b/features/rageshake/src/test/kotlin/io/element/android/features/rageshake/bugreport/BugReportPresenterTest.kt @@ -0,0 +1,247 @@ +/* + * 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. + */ + +@file:OptIn(ExperimentalCoroutinesApi::class) + +package io.element.android.features.rageshake.bugreport + +import app.cash.molecule.RecompositionClock +import app.cash.molecule.moleculeFlow +import app.cash.turbine.test +import com.google.common.truth.Truth.assertThat +import io.element.android.features.rageshake.crash.ui.A_CRASH_DATA +import io.element.android.features.rageshake.crash.ui.FakeCrashDataStore +import io.element.android.libraries.architecture.Async +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.test.runTest +import org.junit.Test + +const val A_SHORT_DESCRIPTION = "bug!" +const val A_LONG_DESCRIPTION = "I have seen a bug!" + +class BugReportPresenterTest { + @Test + fun `present - initial state`() = runTest { + val presenter = BugReportPresenter( + FakeBugReporter(), + FakeCrashDataStore(), + FakeScreenshotHolder(), + this, + ) + moleculeFlow(RecompositionClock.Immediate) { + presenter.present() + }.test { + val initialState = awaitItem() + assertThat(initialState.hasCrashLogs).isFalse() + assertThat(initialState.formState).isEqualTo(BugReportFormState.Default) + assertThat(initialState.sending).isEqualTo(Async.Uninitialized) + assertThat(initialState.screenshotUri).isNull() + assertThat(initialState.sendingProgress).isEqualTo(0f) + assertThat(initialState.submitEnabled).isFalse() + } + } + + @Test + fun `present - set description`() = runTest { + val presenter = BugReportPresenter( + FakeBugReporter(), + FakeCrashDataStore(), + FakeScreenshotHolder(), + this, + ) + moleculeFlow(RecompositionClock.Immediate) { + presenter.present() + }.test { + val initialState = awaitItem() + initialState.eventSink.invoke(BugReportEvents.SetDescription(A_SHORT_DESCRIPTION)) + assertThat(awaitItem().submitEnabled).isFalse() + initialState.eventSink.invoke(BugReportEvents.SetDescription(A_LONG_DESCRIPTION)) + assertThat(awaitItem().submitEnabled).isTrue() + } + } + + @Test + fun `present - can contact`() = runTest { + val presenter = BugReportPresenter( + FakeBugReporter(), + FakeCrashDataStore(), + FakeScreenshotHolder(), + this, + ) + moleculeFlow(RecompositionClock.Immediate) { + presenter.present() + }.test { + val initialState = awaitItem() + initialState.eventSink.invoke(BugReportEvents.SetCanContact(true)) + assertThat(awaitItem().formState).isEqualTo(BugReportFormState.Default.copy(canContact = true)) + initialState.eventSink.invoke(BugReportEvents.SetCanContact(false)) + assertThat(awaitItem().formState).isEqualTo(BugReportFormState.Default.copy(canContact = false)) + } + } + + @Test + fun `present - send crash logs`() = runTest { + val presenter = BugReportPresenter( + FakeBugReporter(), + FakeCrashDataStore(), + FakeScreenshotHolder(), + this, + ) + moleculeFlow(RecompositionClock.Immediate) { + presenter.present() + }.test { + val initialState = awaitItem() + // Since this is true by default, start by disabling + initialState.eventSink.invoke(BugReportEvents.SetSendCrashLog(false)) + assertThat(awaitItem().formState).isEqualTo(BugReportFormState.Default.copy(sendCrashLogs = false)) + initialState.eventSink.invoke(BugReportEvents.SetSendCrashLog(true)) + assertThat(awaitItem().formState).isEqualTo(BugReportFormState.Default.copy(sendCrashLogs = true)) + } + } + + @Test + fun `present - send logs`() = runTest { + val presenter = BugReportPresenter( + FakeBugReporter(), + FakeCrashDataStore(), + FakeScreenshotHolder(), + this, + ) + moleculeFlow(RecompositionClock.Immediate) { + presenter.present() + }.test { + val initialState = awaitItem() + // Since this is true by default, start by disabling + initialState.eventSink.invoke(BugReportEvents.SetSendLog(false)) + assertThat(awaitItem().formState).isEqualTo(BugReportFormState.Default.copy(sendLogs = false)) + initialState.eventSink.invoke(BugReportEvents.SetSendLog(true)) + assertThat(awaitItem().formState).isEqualTo(BugReportFormState.Default.copy(sendLogs = true)) + } + } + + @Test + fun `present - send screenshot`() = runTest { + val presenter = BugReportPresenter( + FakeBugReporter(), + FakeCrashDataStore(), + FakeScreenshotHolder(), + this, + ) + moleculeFlow(RecompositionClock.Immediate) { + presenter.present() + }.test { + val initialState = awaitItem() + initialState.eventSink.invoke(BugReportEvents.SetSendScreenshot(true)) + assertThat(awaitItem().formState).isEqualTo(BugReportFormState.Default.copy(sendScreenshot = true)) + initialState.eventSink.invoke(BugReportEvents.SetSendScreenshot(false)) + assertThat(awaitItem().formState).isEqualTo(BugReportFormState.Default.copy(sendScreenshot = false)) + } + } + + @Test + fun `present - reset all`() = runTest { + val presenter = BugReportPresenter( + FakeBugReporter(), + FakeCrashDataStore(crashData = A_CRASH_DATA, appHasCrashed = true), + FakeScreenshotHolder(screenshotUri = A_SCREENSHOT_URI), + this, + ) + moleculeFlow(RecompositionClock.Immediate) { + presenter.present() + }.test { + skipItems(1) + val initialState = awaitItem() + assertThat(initialState.hasCrashLogs).isTrue() + assertThat(initialState.screenshotUri).isEqualTo(A_SCREENSHOT_URI) + initialState.eventSink.invoke(BugReportEvents.ResetAll) + val resetState = awaitItem() + assertThat(resetState.hasCrashLogs).isFalse() + // TODO Make it live assertThat(resetState.screenshotUri).isNull() + } + } + + @Test + fun `present - send success`() = runTest { + val presenter = BugReportPresenter( + FakeBugReporter(mode = FakeBugReporterMode.Success), + FakeCrashDataStore(crashData = A_CRASH_DATA, appHasCrashed = true), + FakeScreenshotHolder(screenshotUri = A_SCREENSHOT_URI), + this, + ) + moleculeFlow(RecompositionClock.Immediate) { + presenter.present() + }.test { + val initialState = awaitItem() + initialState.eventSink.invoke(BugReportEvents.SendBugReport) + skipItems(1) + val progressState = awaitItem() + assertThat(progressState.sending).isEqualTo(Async.Loading(null)) + assertThat(progressState.sendingProgress).isEqualTo(0f) + assertThat(awaitItem().sendingProgress).isEqualTo(0.5f) + assertThat(awaitItem().sendingProgress).isEqualTo(1f) + skipItems(1) + assertThat(awaitItem().sending).isEqualTo(Async.Success(Unit)) + } + } + + @Test + fun `present - send failure`() = runTest { + val presenter = BugReportPresenter( + FakeBugReporter(mode = FakeBugReporterMode.Failure), + FakeCrashDataStore(crashData = A_CRASH_DATA, appHasCrashed = true), + FakeScreenshotHolder(screenshotUri = A_SCREENSHOT_URI), + this, + ) + moleculeFlow(RecompositionClock.Immediate) { + presenter.present() + }.test { + val initialState = awaitItem() + initialState.eventSink.invoke(BugReportEvents.SendBugReport) + skipItems(1) + val progressState = awaitItem() + assertThat(progressState.sending).isEqualTo(Async.Loading(null)) + assertThat(progressState.sendingProgress).isEqualTo(0f) + assertThat(awaitItem().sendingProgress).isEqualTo(0.5f) + // Failure + assertThat(awaitItem().sendingProgress).isEqualTo(0f) + assertThat((awaitItem().sending as Async.Failure).error.message).isEqualTo(A_REASON) + } + } + + @Test + fun `present - send cancel`() = runTest { + val presenter = BugReportPresenter( + FakeBugReporter(mode = FakeBugReporterMode.Cancel), + FakeCrashDataStore(crashData = A_CRASH_DATA, appHasCrashed = true), + FakeScreenshotHolder(screenshotUri = A_SCREENSHOT_URI), + this, + ) + moleculeFlow(RecompositionClock.Immediate) { + presenter.present() + }.test { + val initialState = awaitItem() + initialState.eventSink.invoke(BugReportEvents.SendBugReport) + skipItems(1) + val progressState = awaitItem() + assertThat(progressState.sending).isEqualTo(Async.Loading(null)) + assertThat(progressState.sendingProgress).isEqualTo(0f) + assertThat(awaitItem().sendingProgress).isEqualTo(0.5f) + // Cancelled + assertThat(awaitItem().sendingProgress).isEqualTo(0f) + assertThat(awaitItem().sending).isEqualTo(Async.Uninitialized) + } + } +} diff --git a/features/rageshake/src/test/kotlin/io/element/android/features/rageshake/bugreport/FakeBugReporter.kt b/features/rageshake/src/test/kotlin/io/element/android/features/rageshake/bugreport/FakeBugReporter.kt new file mode 100644 index 0000000000..a1a2c613a7 --- /dev/null +++ b/features/rageshake/src/test/kotlin/io/element/android/features/rageshake/bugreport/FakeBugReporter.kt @@ -0,0 +1,70 @@ +/* + * 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.features.rageshake.bugreport + +import io.element.android.features.rageshake.reporter.BugReporter +import io.element.android.features.rageshake.reporter.BugReporterListener +import io.element.android.features.rageshake.reporter.ReportType +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.delay +import kotlinx.coroutines.launch + +const val A_REASON = "There has been a failure" + +class FakeBugReporter(val mode: FakeBugReporterMode = FakeBugReporterMode.Success) : BugReporter { + override fun sendBugReport( + coroutineScope: CoroutineScope, + reportType: ReportType, + withDevicesLogs: Boolean, + withCrashLogs: Boolean, + withKeyRequestHistory: Boolean, + withScreenshot: Boolean, + theBugDescription: String, + serverVersion: String, + canContact: Boolean, + customFields: Map?, + listener: BugReporterListener?, + ) { + coroutineScope.launch { + delay(100) + listener?.onProgress(0) + delay(100) + listener?.onProgress(50) + delay(100) + when (mode) { + FakeBugReporterMode.Success -> Unit + FakeBugReporterMode.Failure -> { + listener?.onUploadFailed(A_REASON) + return@launch + } + FakeBugReporterMode.Cancel -> { + listener?.onUploadCancelled() + return@launch + } + } + listener?.onProgress(100) + delay(100) + listener?.onUploadSucceed(null) + } + } +} + +enum class FakeBugReporterMode { + Success, + Failure, + Cancel +} diff --git a/features/rageshake/src/test/kotlin/io/element/android/features/rageshake/bugreport/FakeScreenshotHolder.kt b/features/rageshake/src/test/kotlin/io/element/android/features/rageshake/bugreport/FakeScreenshotHolder.kt new file mode 100644 index 0000000000..14ece36a14 --- /dev/null +++ b/features/rageshake/src/test/kotlin/io/element/android/features/rageshake/bugreport/FakeScreenshotHolder.kt @@ -0,0 +1,30 @@ +/* + * Copyright (c) 2023 New Vector Ltd + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package io.element.android.features.rageshake.bugreport + +import android.graphics.Bitmap +import io.element.android.features.rageshake.screenshot.ScreenshotHolder + +const val A_SCREENSHOT_URI = "file://content/uri" + +class FakeScreenshotHolder(private val screenshotUri: String? = null) : ScreenshotHolder { + override fun writeBitmap(data: Bitmap) = Unit + + override fun getFileUri() = screenshotUri + + override fun reset() = Unit +} From 8bcc2209a3b900b8b752037eefe7d74fb550b087 Mon Sep 17 00:00:00 2001 From: Benoit Marty Date: Thu, 9 Feb 2023 09:52:03 +0100 Subject: [PATCH 33/51] Improve coverage for `TimelinePresenter` --- .../timeline/TimelinePresenterTest.kt | 26 ++++++++++++++++++- .../matrixtest/timeline/FakeMatrixTimeline.kt | 5 +--- 2 files changed, 26 insertions(+), 5 deletions(-) diff --git a/features/messages/src/test/kotlin/io/element/android/features/messages/timeline/TimelinePresenterTest.kt b/features/messages/src/test/kotlin/io/element/android/features/messages/timeline/TimelinePresenterTest.kt index a1106f738e..1234c8619c 100644 --- a/features/messages/src/test/kotlin/io/element/android/features/messages/timeline/TimelinePresenterTest.kt +++ b/features/messages/src/test/kotlin/io/element/android/features/messages/timeline/TimelinePresenterTest.kt @@ -22,6 +22,8 @@ import app.cash.molecule.RecompositionClock import app.cash.molecule.moleculeFlow import app.cash.turbine.test import com.google.common.truth.Truth.assertThat +import io.element.android.features.messages.timeline.model.TimelineItem +import io.element.android.libraries.matrix.timeline.MatrixTimelineItem import io.element.android.libraries.matrixtest.FakeMatrixClient import io.element.android.libraries.matrixtest.core.A_ROOM_ID import io.element.android.libraries.matrixtest.room.FakeMatrixRoom @@ -43,7 +45,7 @@ class TimelinePresenterTest { presenter.present() }.test { val initialState = awaitItem() - assertThat(initialState.timelineItems.size).isEqualTo(0) + assertThat(initialState.timelineItems).isEmpty() } } @@ -90,4 +92,26 @@ class TimelinePresenterTest { assertThat(withoutHighlightedState.highlightedEventId).isNull() } } + + @Test + fun `present - test callback`() = runTest { + val matrixTimeline = FakeMatrixTimeline() + val matrixRoom = FakeMatrixRoom(A_ROOM_ID, matrixTimeline = matrixTimeline) + val presenter = TimelinePresenter( + testCoroutineDispatchers(), + FakeMatrixClient(), + matrixRoom + ) + moleculeFlow(RecompositionClock.Immediate) { + presenter.present() + }.test { + val initialState = awaitItem() + assertThat(initialState.timelineItems).isEmpty() + // Simulate callback from the SDK + matrixTimeline.callback?.onPushedTimelineItem(MatrixTimelineItem.Virtual) + val nonEmptyState = awaitItem() + assertThat(nonEmptyState.timelineItems).isNotEmpty() + assertThat(nonEmptyState.timelineItems[0]).isEqualTo(TimelineItem.Virtual("virtual_item_0")) + } + } } diff --git a/libraries/matrixtest/src/main/kotlin/io/element/android/libraries/matrixtest/timeline/FakeMatrixTimeline.kt b/libraries/matrixtest/src/main/kotlin/io/element/android/libraries/matrixtest/timeline/FakeMatrixTimeline.kt index 4a24e168dc..417489dc83 100644 --- a/libraries/matrixtest/src/main/kotlin/io/element/android/libraries/matrixtest/timeline/FakeMatrixTimeline.kt +++ b/libraries/matrixtest/src/main/kotlin/io/element/android/libraries/matrixtest/timeline/FakeMatrixTimeline.kt @@ -30,10 +30,7 @@ const val AN_EVENT_ID_VALUE = "!anEventId" val AN_EVENT_ID = EventId(AN_EVENT_ID_VALUE) class FakeMatrixTimeline : MatrixTimeline { - - override var callback: MatrixTimeline.Callback? - get() = null - set(value) {} + override var callback: MatrixTimeline.Callback? = null private var hasMoreToLoadValue: Boolean = true From 2a425fc94cff4192a09e896e390368ae62a0261f Mon Sep 17 00:00:00 2001 From: Benoit Marty Date: Thu, 9 Feb 2023 09:52:34 +0100 Subject: [PATCH 34/51] Cleanup --- .../element/android/features/roomlist/RoomListPresenterTests.kt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/features/roomlist/src/test/kotlin/io/element/android/features/roomlist/RoomListPresenterTests.kt b/features/roomlist/src/test/kotlin/io/element/android/features/roomlist/RoomListPresenterTests.kt index 46c1597475..ad2e02fb51 100644 --- a/features/roomlist/src/test/kotlin/io/element/android/features/roomlist/RoomListPresenterTests.kt +++ b/features/roomlist/src/test/kotlin/io/element/android/features/roomlist/RoomListPresenterTests.kt @@ -131,7 +131,7 @@ class RoomListPresenterTests { skipItems(1) // Filter update val withFilteredRoomState = awaitItem() assertThat(withFilteredRoomState.filter).isEqualTo("tada") - assertThat(withFilteredRoomState.roomList.size).isEqualTo(0) + assertThat(withFilteredRoomState.roomList).isEmpty() } } From 4cb433f79ab2e4a8ad53b0ec0c7a7d994d330ef5 Mon Sep 17 00:00:00 2001 From: Benoit Marty Date: Thu, 9 Feb 2023 10:34:45 +0100 Subject: [PATCH 35/51] Add test for `RageshakeDetectionPresenter` --- .../RageshakeDetectionPresenterTest.kt | 152 ++++++++++++++++++ .../rageshake/preferences/FakeRageShake.kt | 5 + 2 files changed, 157 insertions(+) create mode 100644 features/rageshake/src/test/kotlin/io/element/android/features/rageshake/detection/RageshakeDetectionPresenterTest.kt diff --git a/features/rageshake/src/test/kotlin/io/element/android/features/rageshake/detection/RageshakeDetectionPresenterTest.kt b/features/rageshake/src/test/kotlin/io/element/android/features/rageshake/detection/RageshakeDetectionPresenterTest.kt new file mode 100644 index 0000000000..6b0d1e38f7 --- /dev/null +++ b/features/rageshake/src/test/kotlin/io/element/android/features/rageshake/detection/RageshakeDetectionPresenterTest.kt @@ -0,0 +1,152 @@ +/* + * 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. + */ + +@file:OptIn(ExperimentalCoroutinesApi::class) + +package io.element.android.features.rageshake.detection + +import app.cash.molecule.RecompositionClock +import app.cash.molecule.moleculeFlow +import app.cash.turbine.test +import com.google.common.truth.Truth.assertThat +import io.element.android.features.rageshake.bugreport.FakeScreenshotHolder +import io.element.android.features.rageshake.preferences.FakeRageShake +import io.element.android.features.rageshake.preferences.FakeRageshakeDataStore +import io.element.android.features.rageshake.preferences.RageshakePreferencesPresenter +import io.element.android.features.rageshake.screenshot.ImageResult +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.test.runTest +import org.junit.Test + +class RageshakeDetectionPresenterTest { + @Test + fun `present - initial state`() = runTest { + val screenshotHolder = FakeScreenshotHolder(screenshotUri = null) + val rageshake = FakeRageShake(isAvailableValue = true) + val rageshakeDataStore = FakeRageshakeDataStore(isEnabled = true) + val presenter = RageshakeDetectionPresenter( + screenshotHolder = screenshotHolder, + rageShake = rageshake, + preferencesPresenter = RageshakePreferencesPresenter( + rageshake = rageshake, + rageshakeDataStore = rageshakeDataStore, + ) + ) + moleculeFlow(RecompositionClock.Immediate) { + presenter.present() + }.test { + skipItems(1) + val initialState = awaitItem() + assertThat(initialState.takeScreenshot).isFalse() + assertThat(initialState.showDialog).isFalse() + assertThat(initialState.isStarted).isFalse() + } + } + + @Test + fun `present - start and stop detection`() = runTest { + val screenshotHolder = FakeScreenshotHolder(screenshotUri = null) + val rageshake = FakeRageShake(isAvailableValue = true) + val rageshakeDataStore = FakeRageshakeDataStore(isEnabled = true) + val presenter = RageshakeDetectionPresenter( + screenshotHolder = screenshotHolder, + rageShake = rageshake, + preferencesPresenter = RageshakePreferencesPresenter( + rageshake = rageshake, + rageshakeDataStore = rageshakeDataStore, + ) + ) + moleculeFlow(RecompositionClock.Immediate) { + presenter.present() + }.test { + skipItems(1) + val initialState = awaitItem() + initialState.eventSink.invoke(RageshakeDetectionEvents.StartDetection) + assertThat(awaitItem().isStarted).isTrue() + initialState.eventSink.invoke(RageshakeDetectionEvents.StopDetection) + assertThat(awaitItem().isStarted).isFalse() + } + } + + @Test + fun `present - screenshot then dismiss`() = runTest { + val screenshotHolder = FakeScreenshotHolder(screenshotUri = null) + val rageshake = FakeRageShake(isAvailableValue = true) + val rageshakeDataStore = FakeRageshakeDataStore(isEnabled = true) + val presenter = RageshakeDetectionPresenter( + screenshotHolder = screenshotHolder, + rageShake = rageshake, + preferencesPresenter = RageshakePreferencesPresenter( + rageshake = rageshake, + rageshakeDataStore = rageshakeDataStore, + ) + ) + moleculeFlow(RecompositionClock.Immediate) { + presenter.present() + }.test { + skipItems(1) + val initialState = awaitItem() + assertThat(initialState.isStarted).isFalse() + initialState.eventSink.invoke(RageshakeDetectionEvents.StartDetection) + assertThat(awaitItem().isStarted).isTrue() + rageshake.triggerPhoneRageshake() + assertThat(awaitItem().takeScreenshot).isTrue() + initialState.eventSink.invoke( + RageshakeDetectionEvents.ProcessScreenshot(ImageResult.Error(Exception("Error"))) + ) + assertThat(awaitItem().showDialog).isTrue() + initialState.eventSink.invoke(RageshakeDetectionEvents.Dismiss) + val finalState = awaitItem() + assertThat(finalState.showDialog).isFalse() + assertThat(rageshakeDataStore.isEnabled().first()).isTrue() + } + } + + @Test + fun `present - screenshot then disable`() = runTest { + val screenshotHolder = FakeScreenshotHolder(screenshotUri = null) + val rageshake = FakeRageShake(isAvailableValue = true) + val rageshakeDataStore = FakeRageshakeDataStore(isEnabled = true) + val presenter = RageshakeDetectionPresenter( + screenshotHolder = screenshotHolder, + rageShake = rageshake, + preferencesPresenter = RageshakePreferencesPresenter( + rageshake = rageshake, + rageshakeDataStore = rageshakeDataStore, + ) + ) + moleculeFlow(RecompositionClock.Immediate) { + presenter.present() + }.test { + skipItems(1) + val initialState = awaitItem() + assertThat(initialState.isStarted).isFalse() + initialState.eventSink.invoke(RageshakeDetectionEvents.StartDetection) + assertThat(awaitItem().isStarted).isTrue() + rageshake.triggerPhoneRageshake() + assertThat(awaitItem().takeScreenshot).isTrue() + initialState.eventSink.invoke( + RageshakeDetectionEvents.ProcessScreenshot(ImageResult.Error(Exception("Error"))) + ) + assertThat(awaitItem().showDialog).isTrue() + initialState.eventSink.invoke(RageshakeDetectionEvents.Disable) + skipItems(1) + assertThat(awaitItem().showDialog).isFalse() + assertThat(rageshakeDataStore.isEnabled().first()).isFalse() + } + } +} diff --git a/features/rageshake/src/test/kotlin/io/element/android/features/rageshake/preferences/FakeRageShake.kt b/features/rageshake/src/test/kotlin/io/element/android/features/rageshake/preferences/FakeRageShake.kt index 6d0669cd7c..fbabdaac5d 100644 --- a/features/rageshake/src/test/kotlin/io/element/android/features/rageshake/preferences/FakeRageShake.kt +++ b/features/rageshake/src/test/kotlin/io/element/android/features/rageshake/preferences/FakeRageShake.kt @@ -24,6 +24,8 @@ class FakeRageShake( private var isAvailableValue: Boolean = true ) : RageShake { + private var interceptor: (() -> Unit)? = null + override fun isAvailable() = isAvailableValue override fun start(sensitivity: Float) { @@ -36,5 +38,8 @@ class FakeRageShake( } override fun setInterceptor(interceptor: (() -> Unit)?) { + this.interceptor = interceptor } + + fun triggerPhoneRageshake() = interceptor?.invoke() } From e2de9d04100be92c3c8cc188cf0d27a1ba508cf5 Mon Sep 17 00:00:00 2001 From: Benoit Marty Date: Thu, 9 Feb 2023 10:43:37 +0100 Subject: [PATCH 36/51] Less verbose provider. --- .../kotlin/io/element/android/x/di/AppModule.kt | 16 +--------------- 1 file changed, 1 insertion(+), 15 deletions(-) diff --git a/app/src/main/kotlin/io/element/android/x/di/AppModule.kt b/app/src/main/kotlin/io/element/android/x/di/AppModule.kt index c41ae86dff..b562abe93e 100644 --- a/app/src/main/kotlin/io/element/android/x/di/AppModule.kt +++ b/app/src/main/kotlin/io/element/android/x/di/AppModule.kt @@ -20,10 +20,8 @@ import android.content.Context import com.squareup.anvil.annotations.ContributesTo import dagger.Module import dagger.Provides -import io.element.android.features.rageshake.crash.CrashDataStore import io.element.android.features.rageshake.reporter.BugReporter import io.element.android.features.rageshake.reporter.DefaultBugReporter -import io.element.android.features.rageshake.screenshot.ScreenshotHolder import io.element.android.libraries.core.coroutine.CoroutineDispatchers import io.element.android.libraries.di.AppScope import io.element.android.libraries.di.ApplicationContext @@ -64,17 +62,5 @@ object AppModule { } @Provides - fun provideBugReporter( - @ApplicationContext context: Context, - screenshotHolder: ScreenshotHolder, - crashDataStore: CrashDataStore, - coroutineDispatchers: CoroutineDispatchers, - ): BugReporter { - return DefaultBugReporter( - context = context, - screenshotHolder = screenshotHolder, - crashDataStore = crashDataStore, - coroutineDispatchers = coroutineDispatchers, - ) - } + fun providesBugReporter(bugReporter: DefaultBugReporter): BugReporter = bugReporter } From 310a8ca8e3fb379a76894f837fc363ffb17793eb Mon Sep 17 00:00:00 2001 From: Benoit Marty Date: Thu, 9 Feb 2023 12:04:31 +0100 Subject: [PATCH 37/51] Fix dependency issue. --- app/build.gradle.kts | 2 -- features/rageshake/build.gradle.kts | 2 +- libraries/dateformatter/build.gradle.kts | 2 +- 3 files changed, 2 insertions(+), 4 deletions(-) diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 305173d99f..f5a0799676 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -175,8 +175,6 @@ dependencies { implementation(libs.androidx.activity.compose) implementation(libs.androidx.startup) implementation(libs.coil) - implementation(libs.datetime) - implementation(libs.squareup.seismic) implementation(libs.dagger) kapt(libs.dagger.compiler) diff --git a/features/rageshake/build.gradle.kts b/features/rageshake/build.gradle.kts index d1aee8d989..17160b37ab 100644 --- a/features/rageshake/build.gradle.kts +++ b/features/rageshake/build.gradle.kts @@ -40,7 +40,7 @@ dependencies { implementation(projects.libraries.designsystem) implementation(projects.libraries.elementresources) implementation(projects.libraries.uiStrings) - implementation(libs.squareup.seismic) + api(libs.squareup.seismic) implementation(libs.androidx.datastore.preferences) implementation(libs.coil) implementation(libs.coil.compose) diff --git a/libraries/dateformatter/build.gradle.kts b/libraries/dateformatter/build.gradle.kts index 817435f8ac..60ecd95052 100644 --- a/libraries/dateformatter/build.gradle.kts +++ b/libraries/dateformatter/build.gradle.kts @@ -34,7 +34,7 @@ android { implementation(libs.dagger) implementation(projects.libraries.di) implementation(projects.anvilannotations) - implementation(libs.datetime) + api(libs.datetime) ksp(libs.showkase.processor) testImplementation(libs.test.junit) From af5277f5cc789981c933f10ed185727ed0a4bc4f Mon Sep 17 00:00:00 2001 From: Benoit Marty Date: Thu, 9 Feb 2023 12:19:04 +0100 Subject: [PATCH 38/51] Add test for `PreferencesRootPresenter` --- features/preferences/build.gradle.kts | 7 +++ .../features/preferences/ExampleUnitTest.kt | 32 ----------- .../preferences/root/FakeRageShake.kt | 44 +++++++++++++++ .../root/FakeRageshakeDataStore.kt | 46 ++++++++++++++++ .../root/PreferencesRootPresenterTest.kt | 53 +++++++++++++++++++ 5 files changed, 150 insertions(+), 32 deletions(-) delete mode 100644 features/preferences/src/test/kotlin/io/element/android/features/preferences/ExampleUnitTest.kt create mode 100644 features/preferences/src/test/kotlin/io/element/android/features/preferences/root/FakeRageShake.kt create mode 100644 features/preferences/src/test/kotlin/io/element/android/features/preferences/root/FakeRageshakeDataStore.kt create mode 100644 features/preferences/src/test/kotlin/io/element/android/features/preferences/root/PreferencesRootPresenterTest.kt diff --git a/features/preferences/build.gradle.kts b/features/preferences/build.gradle.kts index 92dcb5b13d..ba069e6333 100644 --- a/features/preferences/build.gradle.kts +++ b/features/preferences/build.gradle.kts @@ -44,7 +44,14 @@ dependencies { implementation(projects.libraries.uiStrings) implementation(libs.datetime) implementation(libs.accompanist.placeholder) + testImplementation(libs.test.junit) + testImplementation(libs.coroutines.test) + testImplementation(libs.molecule.runtime) + testImplementation(libs.test.truth) + testImplementation(libs.test.turbine) + testImplementation(projects.libraries.matrixtest) + androidTestImplementation(libs.test.junitext) ksp(libs.showkase.processor) } diff --git a/features/preferences/src/test/kotlin/io/element/android/features/preferences/ExampleUnitTest.kt b/features/preferences/src/test/kotlin/io/element/android/features/preferences/ExampleUnitTest.kt deleted file mode 100644 index 3b615c83e9..0000000000 --- a/features/preferences/src/test/kotlin/io/element/android/features/preferences/ExampleUnitTest.kt +++ /dev/null @@ -1,32 +0,0 @@ -/* - * Copyright (c) 2022 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.features.preferences - -import org.junit.Assert.assertEquals -import org.junit.Test - -/** - * Example local unit test, which will execute on the development machine (host). - * - * See [testing documentation](http://d.android.com/tools/testing). - */ -class ExampleUnitTest { - @Test - fun addition_isCorrect() { - assertEquals(4, 2 + 2) - } -} diff --git a/features/preferences/src/test/kotlin/io/element/android/features/preferences/root/FakeRageShake.kt b/features/preferences/src/test/kotlin/io/element/android/features/preferences/root/FakeRageShake.kt new file mode 100644 index 0000000000..9c1d69828d --- /dev/null +++ b/features/preferences/src/test/kotlin/io/element/android/features/preferences/root/FakeRageShake.kt @@ -0,0 +1,44 @@ +/* + * 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.features.preferences.root + +import io.element.android.features.rageshake.rageshake.RageShake + +// TODO Remove this duplicated class when we will rework modules. +class FakeRageShake( + private var isAvailableValue: Boolean = true +) : RageShake { + + private var interceptor: (() -> Unit)? = null + + override fun isAvailable() = isAvailableValue + + override fun start(sensitivity: Float) { + } + + override fun stop() { + } + + override fun setSensitivity(sensitivity: Float) { + } + + override fun setInterceptor(interceptor: (() -> Unit)?) { + this.interceptor = interceptor + } + + fun triggerPhoneRageshake() = interceptor?.invoke() +} diff --git a/features/preferences/src/test/kotlin/io/element/android/features/preferences/root/FakeRageshakeDataStore.kt b/features/preferences/src/test/kotlin/io/element/android/features/preferences/root/FakeRageshakeDataStore.kt new file mode 100644 index 0000000000..517d587bec --- /dev/null +++ b/features/preferences/src/test/kotlin/io/element/android/features/preferences/root/FakeRageshakeDataStore.kt @@ -0,0 +1,46 @@ +/* + * 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.features.preferences.root + +import io.element.android.features.rageshake.rageshake.RageshakeDataStore +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.MutableStateFlow + +const val A_SENSITIVITY = 1f + +// TODO Remove this duplicated class when we will rework modules. +class FakeRageshakeDataStore( + isEnabled: Boolean = true, + sensitivity: Float = A_SENSITIVITY, +) : RageshakeDataStore { + + private val isEnabledFlow = MutableStateFlow(isEnabled) + override fun isEnabled(): Flow = isEnabledFlow + + override suspend fun setIsEnabled(isEnabled: Boolean) { + isEnabledFlow.value = isEnabled + } + + private val sensitivityFlow = MutableStateFlow(sensitivity) + override fun sensitivity(): Flow = sensitivityFlow + + override suspend fun setSensitivity(sensitivity: Float) { + sensitivityFlow.value = sensitivity + } + + override suspend fun reset() = Unit +} diff --git a/features/preferences/src/test/kotlin/io/element/android/features/preferences/root/PreferencesRootPresenterTest.kt b/features/preferences/src/test/kotlin/io/element/android/features/preferences/root/PreferencesRootPresenterTest.kt new file mode 100644 index 0000000000..77d5ff9a07 --- /dev/null +++ b/features/preferences/src/test/kotlin/io/element/android/features/preferences/root/PreferencesRootPresenterTest.kt @@ -0,0 +1,53 @@ +/* + * 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. + */ + +@file:OptIn(ExperimentalCoroutinesApi::class) + +package io.element.android.features.preferences.root + +import app.cash.molecule.RecompositionClock +import app.cash.molecule.moleculeFlow +import app.cash.turbine.test +import com.google.common.truth.Truth.assertThat +import io.element.android.features.logout.LogoutPreferencePresenter +import io.element.android.features.rageshake.preferences.RageshakePreferencesPresenter +import io.element.android.libraries.architecture.Async +import io.element.android.libraries.matrixtest.FakeMatrixClient +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.test.runTest +import org.junit.Test + +class PreferencesRootPresenterTest { + @Test + fun `present - initial state`() = runTest { + val logoutPresenter = LogoutPreferencePresenter(FakeMatrixClient()) + val rageshakePresenter = RageshakePreferencesPresenter(FakeRageShake(), FakeRageshakeDataStore()) + val presenter = PreferencesRootPresenter( + logoutPresenter, rageshakePresenter + ) + moleculeFlow(RecompositionClock.Immediate) { + presenter.present() + }.test { + skipItems(1) + val initialState = awaitItem() + assertThat(initialState.logoutState.logoutAction).isEqualTo(Async.Uninitialized) + assertThat(initialState.rageshakeState.isEnabled).isTrue() + assertThat(initialState.rageshakeState.isSupported).isTrue() + assertThat(initialState.rageshakeState.sensitivity).isEqualTo(1.0f) + assertThat(initialState.myUser).isEqualTo(Async.Uninitialized) + } + } +} From 23f206f7f27a0a475073e7905010cd971a4d38d5 Mon Sep 17 00:00:00 2001 From: Benoit Marty Date: Thu, 9 Feb 2023 12:30:53 +0100 Subject: [PATCH 39/51] Add test for `RootPresenter` --- app/build.gradle.kts | 7 ++ .../element/android/x/root/FakeBugReporter.kt | 71 +++++++++++++++ .../android/x/root/FakeCrashDataStore.kt | 50 +++++++++++ .../element/android/x/root/FakeRageShake.kt | 44 +++++++++ .../android/x/root/FakeRageshakeDataStore.kt | 46 ++++++++++ .../android/x/root/FakeScreenshotHolder.kt | 31 +++++++ .../android/x/root/RootPresenterTest.kt | 89 +++++++++++++++++++ 7 files changed, 338 insertions(+) create mode 100644 app/src/test/kotlin/io/element/android/x/root/FakeBugReporter.kt create mode 100644 app/src/test/kotlin/io/element/android/x/root/FakeCrashDataStore.kt create mode 100644 app/src/test/kotlin/io/element/android/x/root/FakeRageShake.kt create mode 100644 app/src/test/kotlin/io/element/android/x/root/FakeRageshakeDataStore.kt create mode 100644 app/src/test/kotlin/io/element/android/x/root/FakeScreenshotHolder.kt create mode 100644 app/src/test/kotlin/io/element/android/x/root/RootPresenterTest.kt diff --git a/app/build.gradle.kts b/app/build.gradle.kts index f5a0799676..0ee91861a0 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -178,4 +178,11 @@ dependencies { implementation(libs.dagger) kapt(libs.dagger.compiler) + + testImplementation(libs.test.junit) + testImplementation(libs.coroutines.test) + testImplementation(libs.molecule.runtime) + testImplementation(libs.test.truth) + testImplementation(libs.test.turbine) + testImplementation(projects.libraries.matrixtest) } diff --git a/app/src/test/kotlin/io/element/android/x/root/FakeBugReporter.kt b/app/src/test/kotlin/io/element/android/x/root/FakeBugReporter.kt new file mode 100644 index 0000000000..f6cc6c8960 --- /dev/null +++ b/app/src/test/kotlin/io/element/android/x/root/FakeBugReporter.kt @@ -0,0 +1,71 @@ +/* + * 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.x.root + +import io.element.android.features.rageshake.reporter.BugReporter +import io.element.android.features.rageshake.reporter.BugReporterListener +import io.element.android.features.rageshake.reporter.ReportType +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.delay +import kotlinx.coroutines.launch + +const val A_REASON = "There has been a failure" + +// TODO Remove this duplicated class when we will rework modules. +class FakeBugReporter(val mode: FakeBugReporterMode = FakeBugReporterMode.Success) : BugReporter { + override fun sendBugReport( + coroutineScope: CoroutineScope, + reportType: ReportType, + withDevicesLogs: Boolean, + withCrashLogs: Boolean, + withKeyRequestHistory: Boolean, + withScreenshot: Boolean, + theBugDescription: String, + serverVersion: String, + canContact: Boolean, + customFields: Map?, + listener: BugReporterListener?, + ) { + coroutineScope.launch { + delay(100) + listener?.onProgress(0) + delay(100) + listener?.onProgress(50) + delay(100) + when (mode) { + FakeBugReporterMode.Success -> Unit + FakeBugReporterMode.Failure -> { + listener?.onUploadFailed(A_REASON) + return@launch + } + FakeBugReporterMode.Cancel -> { + listener?.onUploadCancelled() + return@launch + } + } + listener?.onProgress(100) + delay(100) + listener?.onUploadSucceed(null) + } + } +} + +enum class FakeBugReporterMode { + Success, + Failure, + Cancel +} diff --git a/app/src/test/kotlin/io/element/android/x/root/FakeCrashDataStore.kt b/app/src/test/kotlin/io/element/android/x/root/FakeCrashDataStore.kt new file mode 100644 index 0000000000..e6ede5ecc0 --- /dev/null +++ b/app/src/test/kotlin/io/element/android/x/root/FakeCrashDataStore.kt @@ -0,0 +1,50 @@ +/* + * 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.x.root + +import io.element.android.features.rageshake.crash.CrashDataStore +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.MutableStateFlow + +const val A_CRASH_DATA = "Some crash data" + +// TODO Remove this duplicated class when we will rework modules. + +class FakeCrashDataStore( + crashData: String = "", + appHasCrashed: Boolean = false, +) : CrashDataStore { + private val appHasCrashedFlow = MutableStateFlow(appHasCrashed) + private val crashDataFlow = MutableStateFlow(crashData) + + override fun setCrashData(crashData: String) { + crashDataFlow.value = crashData + } + + override suspend fun resetAppHasCrashed() { + appHasCrashedFlow.value = false + } + + override fun appHasCrashed(): Flow = appHasCrashedFlow + + override fun crashInfo(): Flow = crashDataFlow + + override suspend fun reset() { + appHasCrashedFlow.value = false + crashDataFlow.value = "" + } +} diff --git a/app/src/test/kotlin/io/element/android/x/root/FakeRageShake.kt b/app/src/test/kotlin/io/element/android/x/root/FakeRageShake.kt new file mode 100644 index 0000000000..9a260d9859 --- /dev/null +++ b/app/src/test/kotlin/io/element/android/x/root/FakeRageShake.kt @@ -0,0 +1,44 @@ +/* + * 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.x.root + +import io.element.android.features.rageshake.rageshake.RageShake + +// TODO Remove this duplicated class when we will rework modules. +class FakeRageShake( + private var isAvailableValue: Boolean = true +) : RageShake { + + private var interceptor: (() -> Unit)? = null + + override fun isAvailable() = isAvailableValue + + override fun start(sensitivity: Float) { + } + + override fun stop() { + } + + override fun setSensitivity(sensitivity: Float) { + } + + override fun setInterceptor(interceptor: (() -> Unit)?) { + this.interceptor = interceptor + } + + fun triggerPhoneRageshake() = interceptor?.invoke() +} diff --git a/app/src/test/kotlin/io/element/android/x/root/FakeRageshakeDataStore.kt b/app/src/test/kotlin/io/element/android/x/root/FakeRageshakeDataStore.kt new file mode 100644 index 0000000000..e8521fb74f --- /dev/null +++ b/app/src/test/kotlin/io/element/android/x/root/FakeRageshakeDataStore.kt @@ -0,0 +1,46 @@ +/* + * 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.x.root + +import io.element.android.features.rageshake.rageshake.RageshakeDataStore +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.MutableStateFlow + +const val A_SENSITIVITY = 1f + +// TODO Remove this duplicated class when we will rework modules. +class FakeRageshakeDataStore( + isEnabled: Boolean = true, + sensitivity: Float = A_SENSITIVITY, +) : RageshakeDataStore { + + private val isEnabledFlow = MutableStateFlow(isEnabled) + override fun isEnabled(): Flow = isEnabledFlow + + override suspend fun setIsEnabled(isEnabled: Boolean) { + isEnabledFlow.value = isEnabled + } + + private val sensitivityFlow = MutableStateFlow(sensitivity) + override fun sensitivity(): Flow = sensitivityFlow + + override suspend fun setSensitivity(sensitivity: Float) { + sensitivityFlow.value = sensitivity + } + + override suspend fun reset() = Unit +} diff --git a/app/src/test/kotlin/io/element/android/x/root/FakeScreenshotHolder.kt b/app/src/test/kotlin/io/element/android/x/root/FakeScreenshotHolder.kt new file mode 100644 index 0000000000..3a44ece6a2 --- /dev/null +++ b/app/src/test/kotlin/io/element/android/x/root/FakeScreenshotHolder.kt @@ -0,0 +1,31 @@ +/* + * 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.x.root + +import android.graphics.Bitmap +import io.element.android.features.rageshake.screenshot.ScreenshotHolder + +const val A_SCREENSHOT_URI = "file://content/uri" + +// TODO Remove this duplicated class when we will rework modules. +class FakeScreenshotHolder(private val screenshotUri: String? = null) : ScreenshotHolder { + override fun writeBitmap(data: Bitmap) = Unit + + override fun getFileUri() = screenshotUri + + override fun reset() = Unit +} diff --git a/app/src/test/kotlin/io/element/android/x/root/RootPresenterTest.kt b/app/src/test/kotlin/io/element/android/x/root/RootPresenterTest.kt new file mode 100644 index 0000000000..3f5a18d567 --- /dev/null +++ b/app/src/test/kotlin/io/element/android/x/root/RootPresenterTest.kt @@ -0,0 +1,89 @@ +/* + * 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. + */ + +@file:OptIn(ExperimentalCoroutinesApi::class) + +package io.element.android.x.root + +import app.cash.molecule.RecompositionClock +import app.cash.molecule.moleculeFlow +import app.cash.turbine.test +import com.google.common.truth.Truth.assertThat +import io.element.android.features.rageshake.bugreport.BugReportPresenter +import io.element.android.features.rageshake.crash.ui.CrashDetectionPresenter +import io.element.android.features.rageshake.detection.RageshakeDetectionPresenter +import io.element.android.features.rageshake.preferences.RageshakePreferencesPresenter +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.test.TestScope +import kotlinx.coroutines.test.runTest +import org.junit.Test + +class RootPresenterTest { + @Test + fun `present - initial state`() = runTest { + val presenter = createPresenter() + moleculeFlow(RecompositionClock.Immediate) { + presenter.present() + }.test { + skipItems(1) + val initialState = awaitItem() + assertThat(initialState.isShowkaseButtonVisible).isTrue() + } + } + + @Test + fun `present - hide showkase button`() = runTest { + val presenter = createPresenter() + moleculeFlow(RecompositionClock.Immediate) { + presenter.present() + }.test { + skipItems(1) + val initialState = awaitItem() + assertThat(initialState.isShowkaseButtonVisible).isTrue() + initialState.eventSink.invoke(RootEvents.HideShowkaseButton) + assertThat(awaitItem().isShowkaseButtonVisible).isFalse() + } + } + + private fun TestScope.createPresenter(): RootPresenter { + val crashDataStore = FakeCrashDataStore() + val rageshakeDataStore = FakeRageshakeDataStore() + val rageshake = FakeRageShake() + val screenshotHolder = FakeScreenshotHolder() + val bugReportPresenter = BugReportPresenter( + bugReporter = FakeBugReporter(), + crashDataStore = crashDataStore, + screenshotHolder = screenshotHolder, + appCoroutineScope = this, + ) + val crashDetectionPresenter = CrashDetectionPresenter( + crashDataStore = crashDataStore + ) + val rageshakeDetectionPresenter = RageshakeDetectionPresenter( + screenshotHolder = screenshotHolder, + rageShake = rageshake, + preferencesPresenter = RageshakePreferencesPresenter( + rageshake = rageshake, + rageshakeDataStore = rageshakeDataStore, + ) + ) + return RootPresenter( + bugReportPresenter = bugReportPresenter, + crashDetectionPresenter = crashDetectionPresenter, + rageshakeDetectionPresenter = rageshakeDetectionPresenter, + ) + } +} From 5292fc9fbc9bb6e5d88b1c8fb1d6e01e51425b50 Mon Sep 17 00:00:00 2001 From: Benoit Marty Date: Thu, 9 Feb 2023 14:27:48 +0100 Subject: [PATCH 40/51] More tests for `RageshakeDetectionPresenterTest` --- features/rageshake/build.gradle.kts | 1 + .../RageshakeDetectionPresenterTest.kt | 43 ++++++++++++++++++- gradle/libs.versions.toml | 2 +- 3 files changed, 43 insertions(+), 3 deletions(-) diff --git a/features/rageshake/build.gradle.kts b/features/rageshake/build.gradle.kts index 17160b37ab..45cc9cdd64 100644 --- a/features/rageshake/build.gradle.kts +++ b/features/rageshake/build.gradle.kts @@ -52,6 +52,7 @@ dependencies { testImplementation(libs.test.truth) testImplementation(libs.test.turbine) testImplementation(projects.libraries.matrixtest) + testImplementation(libs.test.mockk) androidTestImplementation(libs.test.junitext) } diff --git a/features/rageshake/src/test/kotlin/io/element/android/features/rageshake/detection/RageshakeDetectionPresenterTest.kt b/features/rageshake/src/test/kotlin/io/element/android/features/rageshake/detection/RageshakeDetectionPresenterTest.kt index 6b0d1e38f7..f5fb8cbedc 100644 --- a/features/rageshake/src/test/kotlin/io/element/android/features/rageshake/detection/RageshakeDetectionPresenterTest.kt +++ b/features/rageshake/src/test/kotlin/io/element/android/features/rageshake/detection/RageshakeDetectionPresenterTest.kt @@ -18,6 +18,7 @@ package io.element.android.features.rageshake.detection +import android.graphics.Bitmap import app.cash.molecule.RecompositionClock import app.cash.molecule.moleculeFlow import app.cash.turbine.test @@ -27,6 +28,7 @@ import io.element.android.features.rageshake.preferences.FakeRageShake import io.element.android.features.rageshake.preferences.FakeRageshakeDataStore import io.element.android.features.rageshake.preferences.RageshakePreferencesPresenter import io.element.android.features.rageshake.screenshot.ImageResult +import io.mockk.mockk import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.flow.first import kotlinx.coroutines.test.runTest @@ -83,7 +85,41 @@ class RageshakeDetectionPresenterTest { } @Test - fun `present - screenshot then dismiss`() = runTest { + fun `present - screenshot with success then dismiss`() = runTest { + val screenshotHolder = FakeScreenshotHolder(screenshotUri = null) + val rageshake = FakeRageShake(isAvailableValue = true) + val rageshakeDataStore = FakeRageshakeDataStore(isEnabled = true) + val presenter = RageshakeDetectionPresenter( + screenshotHolder = screenshotHolder, + rageShake = rageshake, + preferencesPresenter = RageshakePreferencesPresenter( + rageshake = rageshake, + rageshakeDataStore = rageshakeDataStore, + ) + ) + moleculeFlow(RecompositionClock.Immediate) { + presenter.present() + }.test { + skipItems(1) + val initialState = awaitItem() + assertThat(initialState.isStarted).isFalse() + initialState.eventSink.invoke(RageshakeDetectionEvents.StartDetection) + assertThat(awaitItem().isStarted).isTrue() + rageshake.triggerPhoneRageshake() + assertThat(awaitItem().takeScreenshot).isTrue() + initialState.eventSink.invoke( + RageshakeDetectionEvents.ProcessScreenshot(ImageResult.Success(aBitmap())) + ) + assertThat(awaitItem().showDialog).isTrue() + initialState.eventSink.invoke(RageshakeDetectionEvents.Dismiss) + val finalState = awaitItem() + assertThat(finalState.showDialog).isFalse() + assertThat(rageshakeDataStore.isEnabled().first()).isTrue() + } + } + + @Test + fun `present - screenshot with error then dismiss`() = runTest { val screenshotHolder = FakeScreenshotHolder(screenshotUri = null) val rageshake = FakeRageShake(isAvailableValue = true) val rageshakeDataStore = FakeRageshakeDataStore(isEnabled = true) @@ -140,7 +176,7 @@ class RageshakeDetectionPresenterTest { rageshake.triggerPhoneRageshake() assertThat(awaitItem().takeScreenshot).isTrue() initialState.eventSink.invoke( - RageshakeDetectionEvents.ProcessScreenshot(ImageResult.Error(Exception("Error"))) + RageshakeDetectionEvents.ProcessScreenshot(ImageResult.Success(aBitmap())) ) assertThat(awaitItem().showDialog).isTrue() initialState.eventSink.invoke(RageshakeDetectionEvents.Disable) @@ -150,3 +186,6 @@ class RageshakeDetectionPresenterTest { } } } + +private fun aBitmap(): Bitmap = mockk() + diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 036a9060be..8ed886b47f 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -97,7 +97,7 @@ test_junit = "junit:junit:4.13.2" test_runner = "androidx.test:runner:1.4.0" test_uiautomator = "androidx.test.uiautomator:uiautomator:2.2.0" test_junitext = "androidx.test.ext:junit:1.1.3" -test_mockk = "io.mockk:mockk:1.13.2" +test_mockk = "io.mockk:mockk:1.13.4" test_barista = "com.adevinta.android:barista:4.2.0" test_hamcrest = "org.hamcrest:hamcrest:2.2" test_orchestrator = "androidx.test:orchestrator:1.4.1" From e375454f28f667026b0dc72c6e78c8655b2683b6 Mon Sep 17 00:00:00 2001 From: Benoit Marty Date: Thu, 9 Feb 2023 15:19:32 +0100 Subject: [PATCH 41/51] Test reply and edit message --- .../MessageComposerPresenterTest.kt | 63 +++++++++++++++++++ .../matrixtest/room/FakeMatrixRoom.kt | 14 ++++- .../matrixtest/room/RoomSummaryFixture.kt | 2 + 3 files changed, 77 insertions(+), 2 deletions(-) diff --git a/features/messages/src/test/kotlin/io/element/android/features/messages/textcomposer/MessageComposerPresenterTest.kt b/features/messages/src/test/kotlin/io/element/android/features/messages/textcomposer/MessageComposerPresenterTest.kt index b822caff9e..9a0123b044 100644 --- a/features/messages/src/test/kotlin/io/element/android/features/messages/textcomposer/MessageComposerPresenterTest.kt +++ b/features/messages/src/test/kotlin/io/element/android/features/messages/textcomposer/MessageComposerPresenterTest.kt @@ -25,7 +25,9 @@ import app.cash.turbine.test import com.google.common.truth.Truth.assertThat import io.element.android.libraries.core.data.StableCharSequence import io.element.android.libraries.matrixtest.core.A_ROOM_ID +import io.element.android.libraries.matrixtest.room.ANOTHER_MESSAGE import io.element.android.libraries.matrixtest.room.A_MESSAGE +import io.element.android.libraries.matrixtest.room.A_REPLY import io.element.android.libraries.matrixtest.room.FakeMatrixRoom import io.element.android.libraries.matrixtest.timeline.AN_EVENT_ID import io.element.android.libraries.matrixtest.timeline.A_SENDER_NAME @@ -183,6 +185,67 @@ class MessageComposerPresenterTest { } } + @Test + fun `present - edit message`() = runTest { + val fakeMatrixRoom = FakeMatrixRoom(A_ROOM_ID) + val presenter = MessageComposerPresenter( + this, + fakeMatrixRoom + ) + moleculeFlow(RecompositionClock.Immediate) { + presenter.present() + }.test { + val initialState = awaitItem() + assertThat(initialState.text).isEqualTo(StableCharSequence("")) + val mode = anEditMode() + initialState.eventSink.invoke(MessageComposerEvents.SetMode(mode)) + skipItems(1) + val withMessageState = awaitItem() + assertThat(withMessageState.mode).isEqualTo(mode) + assertThat(withMessageState.text).isEqualTo(StableCharSequence(A_MESSAGE)) + assertThat(withMessageState.isSendButtonVisible).isTrue() + withMessageState.eventSink.invoke(MessageComposerEvents.UpdateText(ANOTHER_MESSAGE)) + val withEditedMessageState = awaitItem() + assertThat(withEditedMessageState.text).isEqualTo(StableCharSequence(ANOTHER_MESSAGE)) + withEditedMessageState.eventSink.invoke(MessageComposerEvents.SendMessage(ANOTHER_MESSAGE)) + skipItems(1) + val messageSentState = awaitItem() + assertThat(messageSentState.text).isEqualTo(StableCharSequence("")) + assertThat(messageSentState.isSendButtonVisible).isFalse() + assertThat(fakeMatrixRoom.editMessageParameter).isEqualTo(ANOTHER_MESSAGE) + } + } + + @Test + fun `present - reply message`() = runTest { + val fakeMatrixRoom = FakeMatrixRoom(A_ROOM_ID) + val presenter = MessageComposerPresenter( + this, + fakeMatrixRoom + ) + moleculeFlow(RecompositionClock.Immediate) { + presenter.present() + }.test { + val initialState = awaitItem() + assertThat(initialState.text).isEqualTo(StableCharSequence("")) + val mode = aReplyMode() + initialState.eventSink.invoke(MessageComposerEvents.SetMode(mode)) + var state = awaitItem() + assertThat(state.mode).isEqualTo(mode) + assertThat(state.text).isEqualTo(StableCharSequence("")) + assertThat(state.isSendButtonVisible).isFalse() + initialState.eventSink.invoke(MessageComposerEvents.UpdateText(A_REPLY)) + val withMessageState = awaitItem() + assertThat(withMessageState.text).isEqualTo(StableCharSequence(A_REPLY)) + assertThat(withMessageState.isSendButtonVisible).isTrue() + withMessageState.eventSink.invoke(MessageComposerEvents.SendMessage(A_REPLY)) + skipItems(1) + val messageSentState = awaitItem() + assertThat(messageSentState.text).isEqualTo(StableCharSequence("")) + assertThat(messageSentState.isSendButtonVisible).isFalse() + assertThat(fakeMatrixRoom.replyMessageParameter).isEqualTo(A_REPLY) + } + } } fun anEditMode() = MessageComposerMode.Edit(AN_EVENT_ID, A_MESSAGE) diff --git a/libraries/matrixtest/src/main/kotlin/io/element/android/libraries/matrixtest/room/FakeMatrixRoom.kt b/libraries/matrixtest/src/main/kotlin/io/element/android/libraries/matrixtest/room/FakeMatrixRoom.kt index 7aac3b95a0..eae9995d13 100644 --- a/libraries/matrixtest/src/main/kotlin/io/element/android/libraries/matrixtest/room/FakeMatrixRoom.kt +++ b/libraries/matrixtest/src/main/kotlin/io/element/android/libraries/matrixtest/room/FakeMatrixRoom.kt @@ -56,12 +56,22 @@ class FakeMatrixRoom( return Result.success(Unit) } + var editMessageParameter: String? = null + private set + override suspend fun editMessage(originalEventId: EventId, message: String): Result { - TODO("Not yet implemented") + editMessageParameter = message + delay(100) + return Result.success(Unit) } + var replyMessageParameter: String? = null + private set + override suspend fun replyMessage(eventId: EventId, message: String): Result { - TODO("Not yet implemented") + replyMessageParameter = message + delay(100) + return Result.success(Unit) } override suspend fun redactEvent(eventId: EventId, reason: String?): Result { diff --git a/libraries/matrixtest/src/main/kotlin/io/element/android/libraries/matrixtest/room/RoomSummaryFixture.kt b/libraries/matrixtest/src/main/kotlin/io/element/android/libraries/matrixtest/room/RoomSummaryFixture.kt index 79d65536bd..6e7fb1de32 100644 --- a/libraries/matrixtest/src/main/kotlin/io/element/android/libraries/matrixtest/room/RoomSummaryFixture.kt +++ b/libraries/matrixtest/src/main/kotlin/io/element/android/libraries/matrixtest/room/RoomSummaryFixture.kt @@ -23,6 +23,8 @@ import io.element.android.libraries.matrixtest.core.A_ROOM_ID const val A_ROOM_NAME = "aRoomName" const val A_MESSAGE = "Hello world!" +const val A_REPLY = "OK, I'll be there!" +const val ANOTHER_MESSAGE = "Hello universe!" fun aRoomSummaryFilled( roomId: RoomId = A_ROOM_ID, From ad62a613cc1c9b777c9834d0ad9a778348895b71 Mon Sep 17 00:00:00 2001 From: Benoit Marty Date: Thu, 9 Feb 2023 16:12:22 +0100 Subject: [PATCH 42/51] Add test for `MessagesPresenter` --- .../features/messages/MessagePresenterTest.kt | 41 ---- .../messages/MessagesPresenterTest.kt | 178 ++++++++++++++++++ .../matrixtest/room/FakeMatrixRoom.kt | 7 +- 3 files changed, 184 insertions(+), 42 deletions(-) delete mode 100644 features/messages/src/test/kotlin/io/element/android/features/messages/MessagePresenterTest.kt create mode 100644 features/messages/src/test/kotlin/io/element/android/features/messages/MessagesPresenterTest.kt diff --git a/features/messages/src/test/kotlin/io/element/android/features/messages/MessagePresenterTest.kt b/features/messages/src/test/kotlin/io/element/android/features/messages/MessagePresenterTest.kt deleted file mode 100644 index c50932f6b8..0000000000 --- a/features/messages/src/test/kotlin/io/element/android/features/messages/MessagePresenterTest.kt +++ /dev/null @@ -1,41 +0,0 @@ -/* - * Copyright (c) 2022 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. - */ - -@file:OptIn(ExperimentalCoroutinesApi::class) - -package io.element.android.features.messages - -import kotlinx.coroutines.ExperimentalCoroutinesApi -import kotlinx.coroutines.test.runTest -import org.junit.Test - -class MessagePresenterTest { - @Test - fun `present - initial state`() = runTest { - /* - TO BE COMPLETED - val presenter = MessagesPresenter( - FakeMatrixClient(SessionId("sessionId")), - ) - moleculeFlow(RecompositionClock.Immediate) { - presenter.present() - }.test { - val initialState = awaitItem() - assertThat(initialState.logoutAction).isEqualTo(Async.Uninitialized) - } - */ - } -} diff --git a/features/messages/src/test/kotlin/io/element/android/features/messages/MessagesPresenterTest.kt b/features/messages/src/test/kotlin/io/element/android/features/messages/MessagesPresenterTest.kt new file mode 100644 index 0000000000..fdd0a29923 --- /dev/null +++ b/features/messages/src/test/kotlin/io/element/android/features/messages/MessagesPresenterTest.kt @@ -0,0 +1,178 @@ +/* + * Copyright (c) 2022 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. + */ + +@file:OptIn(ExperimentalCoroutinesApi::class) + +package io.element.android.features.messages + +import app.cash.molecule.RecompositionClock +import app.cash.molecule.moleculeFlow +import app.cash.turbine.test +import com.google.common.truth.Truth.assertThat +import io.element.android.features.messages.actionlist.ActionListPresenter +import io.element.android.features.messages.actionlist.model.TimelineItemAction +import io.element.android.features.messages.textcomposer.MessageComposerPresenter +import io.element.android.features.messages.timeline.TimelinePresenter +import io.element.android.features.messages.timeline.model.TimelineItem +import io.element.android.features.messages.timeline.model.TimelineItemReactions +import io.element.android.features.messages.timeline.model.content.TimelineItemContent +import io.element.android.features.messages.timeline.model.content.TimelineItemTextContent +import io.element.android.libraries.core.coroutine.CoroutineDispatchers +import io.element.android.libraries.designsystem.components.avatar.AvatarData +import io.element.android.libraries.matrix.room.MatrixRoom +import io.element.android.libraries.matrixtest.FakeMatrixClient +import io.element.android.libraries.matrixtest.core.A_ROOM_ID +import io.element.android.libraries.matrixtest.room.A_MESSAGE +import io.element.android.libraries.matrixtest.room.FakeMatrixRoom +import io.element.android.libraries.matrixtest.timeline.AN_EVENT_ID +import io.element.android.libraries.matrixtest.timeline.A_SENDER_ID +import io.element.android.libraries.matrixtest.timeline.A_SENDER_NAME +import io.element.android.libraries.textcomposer.MessageComposerMode +import kotlinx.collections.immutable.persistentListOf +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.test.TestScope +import kotlinx.coroutines.test.UnconfinedTestDispatcher +import kotlinx.coroutines.test.runTest +import org.junit.Test + +class MessagesPresenterTest { + @Test + fun `present - initial state`() = runTest { + val presenter = createMessagePresenter() + moleculeFlow(RecompositionClock.Immediate) { + presenter.present() + }.test { + skipItems(1) + val initialState = awaitItem() + assertThat(initialState.roomId).isEqualTo(A_ROOM_ID) + } + } + + @Test + fun `present - handle action forward`() = runTest { + val presenter = createMessagePresenter() + moleculeFlow(RecompositionClock.Immediate) { + presenter.present() + }.test { + skipItems(1) + val initialState = awaitItem() + initialState.eventSink.invoke(MessagesEvents.HandleAction(TimelineItemAction.Forward, aMessageEvent())) + // Still a TODO in the code + } + } + + @Test + fun `present - handle action copy`() = runTest { + val presenter = createMessagePresenter() + moleculeFlow(RecompositionClock.Immediate) { + presenter.present() + }.test { + skipItems(1) + val initialState = awaitItem() + initialState.eventSink.invoke(MessagesEvents.HandleAction(TimelineItemAction.Copy, aMessageEvent())) + // Still a TODO in the code + } + } + + @Test + fun `present - handle action reply`() = runTest { + val presenter = createMessagePresenter() + moleculeFlow(RecompositionClock.Immediate) { + presenter.present() + }.test { + skipItems(1) + val initialState = awaitItem() + initialState.eventSink.invoke(MessagesEvents.HandleAction(TimelineItemAction.Reply, aMessageEvent())) + skipItems(1) + val finalState = awaitItem() + assertThat(finalState.composerState.mode).isInstanceOf(MessageComposerMode.Reply::class.java) + } + } + + @Test + fun `present - handle action edit`() = runTest { + val presenter = createMessagePresenter() + moleculeFlow(RecompositionClock.Immediate) { + presenter.present() + }.test { + skipItems(1) + val initialState = awaitItem() + initialState.eventSink.invoke(MessagesEvents.HandleAction(TimelineItemAction.Edit, aMessageEvent())) + skipItems(1) + val finalState = awaitItem() + assertThat(finalState.composerState.mode).isInstanceOf(MessageComposerMode.Edit::class.java) + } + } + + @Test + fun `present - handle action redact`() = runTest { + val matrixRoom = FakeMatrixRoom(A_ROOM_ID) + val presenter = createMessagePresenter(matrixRoom) + moleculeFlow(RecompositionClock.Immediate) { + presenter.present() + }.test { + skipItems(1) + val initialState = awaitItem() + initialState.eventSink.invoke(MessagesEvents.HandleAction(TimelineItemAction.Redact, aMessageEvent())) + assertThat(matrixRoom.redactEventEventIdParam).isEqualTo(AN_EVENT_ID) + } + } + + private fun TestScope.createMessagePresenter( + matrixRoom: MatrixRoom = FakeMatrixRoom(A_ROOM_ID) + ): MessagesPresenter { + val matrixClient = FakeMatrixClient() + val messageComposerPresenter = MessageComposerPresenter( + appCoroutineScope = this, + room = matrixRoom + ) + val timelinePresenter = TimelinePresenter( + coroutineDispatchers = testCoroutineDispatchers(), + client = matrixClient, + room = matrixRoom, + ) + val actionListPresenter = ActionListPresenter() + return MessagesPresenter( + room = matrixRoom, + composerPresenter = messageComposerPresenter, + timelinePresenter = timelinePresenter, + actionListPresenter = actionListPresenter, + ) + } +} + +// TODO Move to common module to reuse +fun testCoroutineDispatchers() = CoroutineDispatchers( + io = UnconfinedTestDispatcher(), + computation = UnconfinedTestDispatcher(), + main = UnconfinedTestDispatcher(), + diffUpdateDispatcher = UnconfinedTestDispatcher(), +) + +// TODO Move to common module to reuse and remove this duplication +private fun aMessageEvent( + isMine: Boolean = true, + content: TimelineItemContent = TimelineItemTextContent(body = A_MESSAGE, htmlDocument = null), +) = TimelineItem.MessageEvent( + id = AN_EVENT_ID, + senderId = A_SENDER_ID, + senderDisplayName = A_SENDER_NAME, + senderAvatar = AvatarData(), + content = content, + sentTime = "", + isMine = isMine, + reactionsState = TimelineItemReactions(persistentListOf()) +) diff --git a/libraries/matrixtest/src/main/kotlin/io/element/android/libraries/matrixtest/room/FakeMatrixRoom.kt b/libraries/matrixtest/src/main/kotlin/io/element/android/libraries/matrixtest/room/FakeMatrixRoom.kt index eae9995d13..6600b0be9a 100644 --- a/libraries/matrixtest/src/main/kotlin/io/element/android/libraries/matrixtest/room/FakeMatrixRoom.kt +++ b/libraries/matrixtest/src/main/kotlin/io/element/android/libraries/matrixtest/room/FakeMatrixRoom.kt @@ -74,7 +74,12 @@ class FakeMatrixRoom( return Result.success(Unit) } + var redactEventEventIdParam: EventId? = null + private set + override suspend fun redactEvent(eventId: EventId, reason: String?): Result { - TODO("Not yet implemented") + redactEventEventIdParam = eventId + delay(100) + return Result.success(Unit) } } From bc862d16687a939249243ef408f05ae7fbba2898 Mon Sep 17 00:00:00 2001 From: Benoit Marty Date: Thu, 9 Feb 2023 17:13:32 +0100 Subject: [PATCH 43/51] Update coverage thresholds --- build.gradle.kts | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/build.gradle.kts b/build.gradle.kts index 6ee2173c49..53a7121cdf 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -185,9 +185,9 @@ koverMerged { name = "Global minimum code coverage." target = kotlinx.kover.api.VerificationTarget.ALL bound { - minValue = 35 + minValue = 40 // Setting a max value, so that if coverage is bigger, it means that we have to change minValue. - maxValue = 40 + maxValue = 45 counter = kotlinx.kover.api.CounterType.LINE valueType = kotlinx.kover.api.VerificationValueType.COVERED_PERCENTAGE } @@ -198,10 +198,11 @@ koverMerged { target = kotlinx.kover.api.VerificationTarget.CLASS overrideClassFilter { includes += "*Presenter" + excludes += "*TemplatePresenter" } bound { - minValue = 80 - counter = kotlinx.kover.api.CounterType.LINE + minValue = 90 + counter = kotlinx.kover.api.CounterType.INSTRUCTION valueType = kotlinx.kover.api.VerificationValueType.COVERED_PERCENTAGE } } From a0e56426c4e79bae198d16b915ef68b3ccb6d684 Mon Sep 17 00:00:00 2001 From: Benoit Marty Date: Thu, 9 Feb 2023 17:44:15 +0100 Subject: [PATCH 44/51] Cleanup and centralize test data. --- .../changeserver/ChangeServerPresenterTest.kt | 2 +- .../login/root/LoginRootPresenterTest.kt | 22 +++++----- .../logout/LogoutPreferencePresenterTest.kt | 2 +- .../messages/MessagesPresenterTest.kt | 18 ++++---- .../actionlist/ActionListPresenterTest.kt | 12 +++--- .../MessageComposerPresenterTest.kt | 33 ++++++++------- .../timeline/TimelinePresenterTest.kt | 11 +++-- .../roomlist/RoomListPresenterTests.kt | 38 ++++++++++++++--- .../libraries/matrixtest/FakeMatrixClient.kt | 9 ++-- .../android/libraries/matrixtest/TestData.kt | 41 +++++++++++++++++++ .../auth/FakeAuthenticationService.kt | 9 +--- .../matrixtest/core/RoomIdFixture.kt | 22 ---------- .../matrixtest/room/FakeMatrixRoom.kt | 3 +- .../matrixtest/room/RoomSummaryFixture.kt | 9 ++-- .../matrixtest/timeline/FakeMatrixTimeline.kt | 5 --- 15 files changed, 135 insertions(+), 101 deletions(-) create mode 100644 libraries/matrixtest/src/main/kotlin/io/element/android/libraries/matrixtest/TestData.kt delete mode 100644 libraries/matrixtest/src/main/kotlin/io/element/android/libraries/matrixtest/core/RoomIdFixture.kt diff --git a/features/login/src/test/kotlin/io/element/android/features/login/changeserver/ChangeServerPresenterTest.kt b/features/login/src/test/kotlin/io/element/android/features/login/changeserver/ChangeServerPresenterTest.kt index c8ff3de1ae..b22c03dc35 100644 --- a/features/login/src/test/kotlin/io/element/android/features/login/changeserver/ChangeServerPresenterTest.kt +++ b/features/login/src/test/kotlin/io/element/android/features/login/changeserver/ChangeServerPresenterTest.kt @@ -23,7 +23,7 @@ import app.cash.molecule.moleculeFlow import app.cash.turbine.test import com.google.common.truth.Truth.assertThat import io.element.android.libraries.architecture.Async -import io.element.android.libraries.matrixtest.auth.A_HOMESERVER +import io.element.android.libraries.matrixtest.A_HOMESERVER import io.element.android.libraries.matrixtest.auth.FakeAuthenticationService import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.test.runTest diff --git a/features/login/src/test/kotlin/io/element/android/features/login/root/LoginRootPresenterTest.kt b/features/login/src/test/kotlin/io/element/android/features/login/root/LoginRootPresenterTest.kt index ba3b5e644a..f9eed8d66f 100644 --- a/features/login/src/test/kotlin/io/element/android/features/login/root/LoginRootPresenterTest.kt +++ b/features/login/src/test/kotlin/io/element/android/features/login/root/LoginRootPresenterTest.kt @@ -23,12 +23,12 @@ import app.cash.molecule.moleculeFlow import app.cash.turbine.test import com.google.common.truth.Truth.assertThat import io.element.android.libraries.matrix.core.SessionId -import io.element.android.libraries.matrixtest.auth.A_FAILURE -import io.element.android.libraries.matrixtest.auth.A_HOMESERVER -import io.element.android.libraries.matrixtest.auth.A_HOMESERVER_2 -import io.element.android.libraries.matrixtest.auth.A_LOGIN -import io.element.android.libraries.matrixtest.auth.A_PASSWORD -import io.element.android.libraries.matrixtest.auth.A_SESSION_ID +import io.element.android.libraries.matrixtest.A_FAILURE +import io.element.android.libraries.matrixtest.A_HOMESERVER +import io.element.android.libraries.matrixtest.A_HOMESERVER_2 +import io.element.android.libraries.matrixtest.A_PASSWORD +import io.element.android.libraries.matrixtest.A_SESSION_ID +import io.element.android.libraries.matrixtest.A_USER_NAME import io.element.android.libraries.matrixtest.auth.FakeAuthenticationService import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.test.runTest @@ -60,13 +60,13 @@ class LoginRootPresenterTest { presenter.present() }.test { val initialState = awaitItem() - initialState.eventSink.invoke(LoginRootEvents.SetLogin(A_LOGIN)) + initialState.eventSink.invoke(LoginRootEvents.SetLogin(A_USER_NAME)) val loginState = awaitItem() - assertThat(loginState.formState).isEqualTo(LoginFormState(login = A_LOGIN, password = "")) + assertThat(loginState.formState).isEqualTo(LoginFormState(login = A_USER_NAME, password = "")) assertThat(loginState.submitEnabled).isFalse() initialState.eventSink.invoke(LoginRootEvents.SetPassword(A_PASSWORD)) val loginAndPasswordState = awaitItem() - assertThat(loginAndPasswordState.formState).isEqualTo(LoginFormState(login = A_LOGIN, password = A_PASSWORD)) + assertThat(loginAndPasswordState.formState).isEqualTo(LoginFormState(login = A_USER_NAME, password = A_PASSWORD)) assertThat(loginAndPasswordState.submitEnabled).isTrue() } } @@ -80,7 +80,7 @@ class LoginRootPresenterTest { presenter.present() }.test { val initialState = awaitItem() - initialState.eventSink.invoke(LoginRootEvents.SetLogin(A_LOGIN)) + initialState.eventSink.invoke(LoginRootEvents.SetLogin(A_USER_NAME)) initialState.eventSink.invoke(LoginRootEvents.SetPassword(A_PASSWORD)) skipItems(1) val loginAndPasswordState = awaitItem() @@ -102,7 +102,7 @@ class LoginRootPresenterTest { presenter.present() }.test { val initialState = awaitItem() - initialState.eventSink.invoke(LoginRootEvents.SetLogin(A_LOGIN)) + initialState.eventSink.invoke(LoginRootEvents.SetLogin(A_USER_NAME)) initialState.eventSink.invoke(LoginRootEvents.SetPassword(A_PASSWORD)) skipItems(1) val loginAndPasswordState = awaitItem() diff --git a/features/logout/src/test/kotlin/io/element/android/features/logout/LogoutPreferencePresenterTest.kt b/features/logout/src/test/kotlin/io/element/android/features/logout/LogoutPreferencePresenterTest.kt index 1f1643d981..5367c21e4d 100644 --- a/features/logout/src/test/kotlin/io/element/android/features/logout/LogoutPreferencePresenterTest.kt +++ b/features/logout/src/test/kotlin/io/element/android/features/logout/LogoutPreferencePresenterTest.kt @@ -24,8 +24,8 @@ import app.cash.turbine.test import com.google.common.truth.Truth.assertThat import io.element.android.libraries.architecture.Async import io.element.android.libraries.matrix.core.SessionId +import io.element.android.libraries.matrixtest.A_FAILURE import io.element.android.libraries.matrixtest.FakeMatrixClient -import io.element.android.libraries.matrixtest.auth.A_FAILURE import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.test.runTest import org.junit.Test diff --git a/features/messages/src/test/kotlin/io/element/android/features/messages/MessagesPresenterTest.kt b/features/messages/src/test/kotlin/io/element/android/features/messages/MessagesPresenterTest.kt index fdd0a29923..a16d9f7eb1 100644 --- a/features/messages/src/test/kotlin/io/element/android/features/messages/MessagesPresenterTest.kt +++ b/features/messages/src/test/kotlin/io/element/android/features/messages/MessagesPresenterTest.kt @@ -33,13 +33,13 @@ import io.element.android.features.messages.timeline.model.content.TimelineItemT import io.element.android.libraries.core.coroutine.CoroutineDispatchers import io.element.android.libraries.designsystem.components.avatar.AvatarData import io.element.android.libraries.matrix.room.MatrixRoom +import io.element.android.libraries.matrixtest.AN_EVENT_ID +import io.element.android.libraries.matrixtest.A_MESSAGE +import io.element.android.libraries.matrixtest.A_ROOM_ID +import io.element.android.libraries.matrixtest.A_USER_ID +import io.element.android.libraries.matrixtest.A_USER_NAME import io.element.android.libraries.matrixtest.FakeMatrixClient -import io.element.android.libraries.matrixtest.core.A_ROOM_ID -import io.element.android.libraries.matrixtest.room.A_MESSAGE import io.element.android.libraries.matrixtest.room.FakeMatrixRoom -import io.element.android.libraries.matrixtest.timeline.AN_EVENT_ID -import io.element.android.libraries.matrixtest.timeline.A_SENDER_ID -import io.element.android.libraries.matrixtest.timeline.A_SENDER_NAME import io.element.android.libraries.textcomposer.MessageComposerMode import kotlinx.collections.immutable.persistentListOf import kotlinx.coroutines.ExperimentalCoroutinesApi @@ -119,7 +119,7 @@ class MessagesPresenterTest { @Test fun `present - handle action redact`() = runTest { - val matrixRoom = FakeMatrixRoom(A_ROOM_ID) + val matrixRoom = FakeMatrixRoom() val presenter = createMessagePresenter(matrixRoom) moleculeFlow(RecompositionClock.Immediate) { presenter.present() @@ -132,7 +132,7 @@ class MessagesPresenterTest { } private fun TestScope.createMessagePresenter( - matrixRoom: MatrixRoom = FakeMatrixRoom(A_ROOM_ID) + matrixRoom: MatrixRoom = FakeMatrixRoom() ): MessagesPresenter { val matrixClient = FakeMatrixClient() val messageComposerPresenter = MessageComposerPresenter( @@ -168,8 +168,8 @@ private fun aMessageEvent( content: TimelineItemContent = TimelineItemTextContent(body = A_MESSAGE, htmlDocument = null), ) = TimelineItem.MessageEvent( id = AN_EVENT_ID, - senderId = A_SENDER_ID, - senderDisplayName = A_SENDER_NAME, + senderId = A_USER_ID.value, + senderDisplayName = A_USER_NAME, senderAvatar = AvatarData(), content = content, sentTime = "", diff --git a/features/messages/src/test/kotlin/io/element/android/features/messages/actionlist/ActionListPresenterTest.kt b/features/messages/src/test/kotlin/io/element/android/features/messages/actionlist/ActionListPresenterTest.kt index 742463d5ed..06d1486293 100644 --- a/features/messages/src/test/kotlin/io/element/android/features/messages/actionlist/ActionListPresenterTest.kt +++ b/features/messages/src/test/kotlin/io/element/android/features/messages/actionlist/ActionListPresenterTest.kt @@ -29,10 +29,10 @@ import io.element.android.features.messages.timeline.model.content.TimelineItemC import io.element.android.features.messages.timeline.model.content.TimelineItemRedactedContent import io.element.android.features.messages.timeline.model.content.TimelineItemTextContent import io.element.android.libraries.designsystem.components.avatar.AvatarData -import io.element.android.libraries.matrixtest.room.A_MESSAGE -import io.element.android.libraries.matrixtest.timeline.AN_EVENT_ID -import io.element.android.libraries.matrixtest.timeline.A_SENDER_ID -import io.element.android.libraries.matrixtest.timeline.A_SENDER_NAME +import io.element.android.libraries.matrixtest.AN_EVENT_ID +import io.element.android.libraries.matrixtest.A_MESSAGE +import io.element.android.libraries.matrixtest.A_USER_ID +import io.element.android.libraries.matrixtest.A_USER_NAME import kotlinx.collections.immutable.persistentListOf import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.test.runTest @@ -166,8 +166,8 @@ private fun aMessageEvent( content: TimelineItemContent, ) = TimelineItem.MessageEvent( id = AN_EVENT_ID, - senderId = A_SENDER_ID, - senderDisplayName = A_SENDER_NAME, + senderId = A_USER_ID.value, + senderDisplayName = A_USER_NAME, senderAvatar = AvatarData(), content = content, sentTime = "", diff --git a/features/messages/src/test/kotlin/io/element/android/features/messages/textcomposer/MessageComposerPresenterTest.kt b/features/messages/src/test/kotlin/io/element/android/features/messages/textcomposer/MessageComposerPresenterTest.kt index 9a0123b044..8278acd13e 100644 --- a/features/messages/src/test/kotlin/io/element/android/features/messages/textcomposer/MessageComposerPresenterTest.kt +++ b/features/messages/src/test/kotlin/io/element/android/features/messages/textcomposer/MessageComposerPresenterTest.kt @@ -24,13 +24,12 @@ import app.cash.turbine.ReceiveTurbine import app.cash.turbine.test import com.google.common.truth.Truth.assertThat import io.element.android.libraries.core.data.StableCharSequence -import io.element.android.libraries.matrixtest.core.A_ROOM_ID -import io.element.android.libraries.matrixtest.room.ANOTHER_MESSAGE -import io.element.android.libraries.matrixtest.room.A_MESSAGE -import io.element.android.libraries.matrixtest.room.A_REPLY +import io.element.android.libraries.matrixtest.ANOTHER_MESSAGE +import io.element.android.libraries.matrixtest.AN_EVENT_ID +import io.element.android.libraries.matrixtest.A_MESSAGE +import io.element.android.libraries.matrixtest.A_REPLY +import io.element.android.libraries.matrixtest.A_USER_NAME import io.element.android.libraries.matrixtest.room.FakeMatrixRoom -import io.element.android.libraries.matrixtest.timeline.AN_EVENT_ID -import io.element.android.libraries.matrixtest.timeline.A_SENDER_NAME import io.element.android.libraries.textcomposer.MessageComposerMode import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.test.runTest @@ -41,7 +40,7 @@ class MessageComposerPresenterTest { fun `present - initial state`() = runTest { val presenter = MessageComposerPresenter( this, - FakeMatrixRoom(A_ROOM_ID) + FakeMatrixRoom() ) moleculeFlow(RecompositionClock.Immediate) { presenter.present() @@ -58,7 +57,7 @@ class MessageComposerPresenterTest { fun `present - toggle fullscreen`() = runTest { val presenter = MessageComposerPresenter( this, - FakeMatrixRoom(A_ROOM_ID) + FakeMatrixRoom() ) moleculeFlow(RecompositionClock.Immediate) { presenter.present() @@ -77,7 +76,7 @@ class MessageComposerPresenterTest { fun `present - change message`() = runTest { val presenter = MessageComposerPresenter( this, - FakeMatrixRoom(A_ROOM_ID) + FakeMatrixRoom() ) moleculeFlow(RecompositionClock.Immediate) { presenter.present() @@ -98,7 +97,7 @@ class MessageComposerPresenterTest { fun `present - change mode to edit`() = runTest { val presenter = MessageComposerPresenter( this, - FakeMatrixRoom(A_ROOM_ID) + FakeMatrixRoom() ) moleculeFlow(RecompositionClock.Immediate) { presenter.present() @@ -128,7 +127,7 @@ class MessageComposerPresenterTest { fun `present - change mode to reply`() = runTest { val presenter = MessageComposerPresenter( this, - FakeMatrixRoom(A_ROOM_ID) + FakeMatrixRoom() ) moleculeFlow(RecompositionClock.Immediate) { presenter.present() @@ -148,7 +147,7 @@ class MessageComposerPresenterTest { fun `present - change mode to quote`() = runTest { val presenter = MessageComposerPresenter( this, - FakeMatrixRoom(A_ROOM_ID) + FakeMatrixRoom() ) moleculeFlow(RecompositionClock.Immediate) { presenter.present() @@ -168,7 +167,7 @@ class MessageComposerPresenterTest { fun `present - send message`() = runTest { val presenter = MessageComposerPresenter( this, - FakeMatrixRoom(A_ROOM_ID) + FakeMatrixRoom() ) moleculeFlow(RecompositionClock.Immediate) { presenter.present() @@ -187,7 +186,7 @@ class MessageComposerPresenterTest { @Test fun `present - edit message`() = runTest { - val fakeMatrixRoom = FakeMatrixRoom(A_ROOM_ID) + val fakeMatrixRoom = FakeMatrixRoom() val presenter = MessageComposerPresenter( this, fakeMatrixRoom @@ -218,7 +217,7 @@ class MessageComposerPresenterTest { @Test fun `present - reply message`() = runTest { - val fakeMatrixRoom = FakeMatrixRoom(A_ROOM_ID) + val fakeMatrixRoom = FakeMatrixRoom() val presenter = MessageComposerPresenter( this, fakeMatrixRoom @@ -230,7 +229,7 @@ class MessageComposerPresenterTest { assertThat(initialState.text).isEqualTo(StableCharSequence("")) val mode = aReplyMode() initialState.eventSink.invoke(MessageComposerEvents.SetMode(mode)) - var state = awaitItem() + val state = awaitItem() assertThat(state.mode).isEqualTo(mode) assertThat(state.text).isEqualTo(StableCharSequence("")) assertThat(state.isSendButtonVisible).isFalse() @@ -249,5 +248,5 @@ class MessageComposerPresenterTest { } fun anEditMode() = MessageComposerMode.Edit(AN_EVENT_ID, A_MESSAGE) -fun aReplyMode() = MessageComposerMode.Reply(A_SENDER_NAME, AN_EVENT_ID, A_MESSAGE) +fun aReplyMode() = MessageComposerMode.Reply(A_USER_NAME, AN_EVENT_ID, A_MESSAGE) fun aQuoteMode() = MessageComposerMode.Quote(AN_EVENT_ID, A_MESSAGE) diff --git a/features/messages/src/test/kotlin/io/element/android/features/messages/timeline/TimelinePresenterTest.kt b/features/messages/src/test/kotlin/io/element/android/features/messages/timeline/TimelinePresenterTest.kt index 1234c8619c..ee73dc260b 100644 --- a/features/messages/src/test/kotlin/io/element/android/features/messages/timeline/TimelinePresenterTest.kt +++ b/features/messages/src/test/kotlin/io/element/android/features/messages/timeline/TimelinePresenterTest.kt @@ -24,10 +24,9 @@ import app.cash.turbine.test import com.google.common.truth.Truth.assertThat import io.element.android.features.messages.timeline.model.TimelineItem import io.element.android.libraries.matrix.timeline.MatrixTimelineItem +import io.element.android.libraries.matrixtest.AN_EVENT_ID import io.element.android.libraries.matrixtest.FakeMatrixClient -import io.element.android.libraries.matrixtest.core.A_ROOM_ID import io.element.android.libraries.matrixtest.room.FakeMatrixRoom -import io.element.android.libraries.matrixtest.timeline.AN_EVENT_ID import io.element.android.libraries.matrixtest.timeline.FakeMatrixTimeline import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.test.runTest @@ -39,7 +38,7 @@ class TimelinePresenterTest { val presenter = TimelinePresenter( testCoroutineDispatchers(), FakeMatrixClient(), - FakeMatrixRoom(A_ROOM_ID) + FakeMatrixRoom() ) moleculeFlow(RecompositionClock.Immediate) { presenter.present() @@ -52,7 +51,7 @@ class TimelinePresenterTest { @Test fun `present - load more`() = runTest { val matrixTimeline = FakeMatrixTimeline() - val matrixRoom = FakeMatrixRoom(A_ROOM_ID, matrixTimeline = matrixTimeline) + val matrixRoom = FakeMatrixRoom(matrixTimeline = matrixTimeline) val presenter = TimelinePresenter( testCoroutineDispatchers(), FakeMatrixClient(), @@ -73,7 +72,7 @@ class TimelinePresenterTest { @Test fun `present - set highlighted event`() = runTest { val matrixTimeline = FakeMatrixTimeline() - val matrixRoom = FakeMatrixRoom(A_ROOM_ID, matrixTimeline = matrixTimeline) + val matrixRoom = FakeMatrixRoom(matrixTimeline = matrixTimeline) val presenter = TimelinePresenter( testCoroutineDispatchers(), FakeMatrixClient(), @@ -96,7 +95,7 @@ class TimelinePresenterTest { @Test fun `present - test callback`() = runTest { val matrixTimeline = FakeMatrixTimeline() - val matrixRoom = FakeMatrixRoom(A_ROOM_ID, matrixTimeline = matrixTimeline) + val matrixRoom = FakeMatrixRoom(matrixTimeline = matrixTimeline) val presenter = TimelinePresenter( testCoroutineDispatchers(), FakeMatrixClient(), diff --git a/features/roomlist/src/test/kotlin/io/element/android/features/roomlist/RoomListPresenterTests.kt b/features/roomlist/src/test/kotlin/io/element/android/features/roomlist/RoomListPresenterTests.kt index ad2e02fb51..e15c08fecc 100644 --- a/features/roomlist/src/test/kotlin/io/element/android/features/roomlist/RoomListPresenterTests.kt +++ b/features/roomlist/src/test/kotlin/io/element/android/features/roomlist/RoomListPresenterTests.kt @@ -27,11 +27,13 @@ import io.element.android.features.roomlist.model.RoomListRoomSummary import io.element.android.libraries.dateformatter.LastMessageFormatter import io.element.android.libraries.designsystem.components.avatar.AvatarData import io.element.android.libraries.matrix.core.SessionId +import io.element.android.libraries.matrixtest.AN_AVATAR_URL +import io.element.android.libraries.matrixtest.A_MESSAGE +import io.element.android.libraries.matrixtest.A_ROOM_ID +import io.element.android.libraries.matrixtest.A_ROOM_NAME +import io.element.android.libraries.matrixtest.A_USER_ID +import io.element.android.libraries.matrixtest.A_USER_NAME import io.element.android.libraries.matrixtest.FakeMatrixClient -import io.element.android.libraries.matrixtest.core.A_ROOM_ID -import io.element.android.libraries.matrixtest.core.A_ROOM_ID_VALUE -import io.element.android.libraries.matrixtest.room.A_MESSAGE -import io.element.android.libraries.matrixtest.room.A_ROOM_NAME import io.element.android.libraries.matrixtest.room.FakeRoomSummaryDataSource import io.element.android.libraries.matrixtest.room.aRoomSummaryFilled import kotlinx.coroutines.ExperimentalCoroutinesApi @@ -55,6 +57,32 @@ class RoomListPresenterTests { assertThat(initialState.matrixUser).isNull() val withUserState = awaitItem() assertThat(withUserState.matrixUser).isNotNull() + assertThat(withUserState.matrixUser!!.id).isEqualTo(A_USER_ID) + assertThat(withUserState.matrixUser!!.username).isEqualTo(A_USER_NAME) + assertThat(withUserState.matrixUser!!.avatarData.name).isEqualTo(A_USER_NAME) + assertThat(withUserState.matrixUser!!.avatarData.url).isEqualTo(AN_AVATAR_URL) + } + } + + @Test + fun `present - should start with no user and then load user with error`() = runTest { + val presenter = RoomListPresenter( + FakeMatrixClient( + SessionId("sessionId"), + userDisplayName = Result.failure(Exception("Error")), + userAvatarURLString = Result.failure(Exception("Error")), + ), + createDateFormatter() + ) + moleculeFlow(RecompositionClock.Immediate) { + presenter.present() + }.test { + val initialState = awaitItem() + assertThat(initialState.matrixUser).isNull() + val withUserState = awaitItem() + assertThat(withUserState.matrixUser).isNotNull() + // username fallback to user id value + assertThat(withUserState.matrixUser!!.username).isEqualTo(A_USER_ID.value) } } @@ -182,7 +210,7 @@ class RoomListPresenterTests { private const val A_FORMATTED_DATE = "formatted_date" private val aRoomListRoomSummary = RoomListRoomSummary( - id = A_ROOM_ID_VALUE, + id = A_ROOM_ID.value, roomId = A_ROOM_ID, name = A_ROOM_NAME, hasUnread = true, diff --git a/libraries/matrixtest/src/main/kotlin/io/element/android/libraries/matrixtest/FakeMatrixClient.kt b/libraries/matrixtest/src/main/kotlin/io/element/android/libraries/matrixtest/FakeMatrixClient.kt index c7aab0952a..aedc0fd1a2 100644 --- a/libraries/matrixtest/src/main/kotlin/io/element/android/libraries/matrixtest/FakeMatrixClient.kt +++ b/libraries/matrixtest/src/main/kotlin/io/element/android/libraries/matrixtest/FakeMatrixClient.kt @@ -23,7 +23,6 @@ import io.element.android.libraries.matrix.core.UserId import io.element.android.libraries.matrix.media.MediaResolver import io.element.android.libraries.matrix.room.MatrixRoom import io.element.android.libraries.matrix.room.RoomSummaryDataSource -import io.element.android.libraries.matrixtest.auth.A_SESSION_ID import io.element.android.libraries.matrixtest.media.FakeMediaResolver import io.element.android.libraries.matrixtest.room.FakeMatrixRoom import io.element.android.libraries.matrixtest.room.FakeRoomSummaryDataSource @@ -32,6 +31,8 @@ import org.matrix.rustcomponents.sdk.MediaSource class FakeMatrixClient( override val sessionId: SessionId = SessionId(A_SESSION_ID), + private val userDisplayName: Result = Result.success(A_USER_NAME), + private val userAvatarURLString: Result = Result.success(AN_AVATAR_URL), val roomSummaryDataSource: RoomSummaryDataSource = FakeRoomSummaryDataSource() ) : MatrixClient { @@ -62,14 +63,14 @@ class FakeMatrixClient( logoutFailure?.let { throw it } } - override fun userId(): UserId = UserId("") + override fun userId(): UserId = A_USER_ID override suspend fun loadUserDisplayName(): Result { - return Result.success("") + return userDisplayName } override suspend fun loadUserAvatarURLString(): Result { - return Result.success("") + return userAvatarURLString } override suspend fun loadMediaContentForSource(source: MediaSource): Result { diff --git a/libraries/matrixtest/src/main/kotlin/io/element/android/libraries/matrixtest/TestData.kt b/libraries/matrixtest/src/main/kotlin/io/element/android/libraries/matrixtest/TestData.kt new file mode 100644 index 0000000000..cd186c50c2 --- /dev/null +++ b/libraries/matrixtest/src/main/kotlin/io/element/android/libraries/matrixtest/TestData.kt @@ -0,0 +1,41 @@ +/* + * 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.matrixtest + +import io.element.android.libraries.matrix.core.EventId +import io.element.android.libraries.matrix.core.RoomId +import io.element.android.libraries.matrix.core.UserId + +const val A_USER_NAME = "alice" +const val A_PASSWORD = "password" + +val A_USER_ID = UserId("@alice:server.org") +val A_ROOM_ID = RoomId("!aRoomId") +val AN_EVENT_ID = EventId("\$anEventId") + +const val A_ROOM_NAME = "A room name" +const val A_MESSAGE = "Hello world!" +const val A_REPLY = "OK, I'll be there!" +const val ANOTHER_MESSAGE = "Hello universe!" + +const val A_HOMESERVER = "matrix.org" +const val A_HOMESERVER_2 = "matrix-client.org" +const val A_SESSION_ID = "sessionId" + +const val AN_AVATAR_URL = "mxc://data" + +val A_FAILURE = Throwable("error") diff --git a/libraries/matrixtest/src/main/kotlin/io/element/android/libraries/matrixtest/auth/FakeAuthenticationService.kt b/libraries/matrixtest/src/main/kotlin/io/element/android/libraries/matrixtest/auth/FakeAuthenticationService.kt index 936bd01545..ab4935ede3 100644 --- a/libraries/matrixtest/src/main/kotlin/io/element/android/libraries/matrixtest/auth/FakeAuthenticationService.kt +++ b/libraries/matrixtest/src/main/kotlin/io/element/android/libraries/matrixtest/auth/FakeAuthenticationService.kt @@ -19,17 +19,12 @@ package io.element.android.libraries.matrixtest.auth import io.element.android.libraries.matrix.MatrixClient import io.element.android.libraries.matrix.auth.MatrixAuthenticationService import io.element.android.libraries.matrix.core.SessionId +import io.element.android.libraries.matrixtest.A_HOMESERVER +import io.element.android.libraries.matrixtest.A_SESSION_ID import kotlinx.coroutines.delay import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.flowOf -const val A_HOMESERVER = "matrix.org" -const val A_HOMESERVER_2 = "matrix-client.org" -const val A_SESSION_ID = "sessionId" -const val A_LOGIN = "login" -const val A_PASSWORD = "password" -val A_FAILURE = Throwable("error") - class FakeAuthenticationService : MatrixAuthenticationService { private var homeserver: String = A_HOMESERVER private var loginError: Throwable? = null diff --git a/libraries/matrixtest/src/main/kotlin/io/element/android/libraries/matrixtest/core/RoomIdFixture.kt b/libraries/matrixtest/src/main/kotlin/io/element/android/libraries/matrixtest/core/RoomIdFixture.kt deleted file mode 100644 index 5b62ad383a..0000000000 --- a/libraries/matrixtest/src/main/kotlin/io/element/android/libraries/matrixtest/core/RoomIdFixture.kt +++ /dev/null @@ -1,22 +0,0 @@ -/* - * 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.matrixtest.core - -import io.element.android.libraries.matrix.core.RoomId - -const val A_ROOM_ID_VALUE = "!aRoomId" -val A_ROOM_ID = RoomId(A_ROOM_ID_VALUE) diff --git a/libraries/matrixtest/src/main/kotlin/io/element/android/libraries/matrixtest/room/FakeMatrixRoom.kt b/libraries/matrixtest/src/main/kotlin/io/element/android/libraries/matrixtest/room/FakeMatrixRoom.kt index 6600b0be9a..18e033b87d 100644 --- a/libraries/matrixtest/src/main/kotlin/io/element/android/libraries/matrixtest/room/FakeMatrixRoom.kt +++ b/libraries/matrixtest/src/main/kotlin/io/element/android/libraries/matrixtest/room/FakeMatrixRoom.kt @@ -20,13 +20,14 @@ import io.element.android.libraries.matrix.core.EventId import io.element.android.libraries.matrix.core.RoomId import io.element.android.libraries.matrix.room.MatrixRoom import io.element.android.libraries.matrix.timeline.MatrixTimeline +import io.element.android.libraries.matrixtest.A_ROOM_ID import io.element.android.libraries.matrixtest.timeline.FakeMatrixTimeline import kotlinx.coroutines.delay import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.emptyFlow class FakeMatrixRoom( - override val roomId: RoomId, + override val roomId: RoomId = A_ROOM_ID, override val name: String? = null, override val bestName: String = "", override val displayName: String = "", diff --git a/libraries/matrixtest/src/main/kotlin/io/element/android/libraries/matrixtest/room/RoomSummaryFixture.kt b/libraries/matrixtest/src/main/kotlin/io/element/android/libraries/matrixtest/room/RoomSummaryFixture.kt index 6e7fb1de32..41d9f6d524 100644 --- a/libraries/matrixtest/src/main/kotlin/io/element/android/libraries/matrixtest/room/RoomSummaryFixture.kt +++ b/libraries/matrixtest/src/main/kotlin/io/element/android/libraries/matrixtest/room/RoomSummaryFixture.kt @@ -19,12 +19,9 @@ package io.element.android.libraries.matrixtest.room import io.element.android.libraries.matrix.core.RoomId import io.element.android.libraries.matrix.room.RoomSummary import io.element.android.libraries.matrix.room.RoomSummaryDetails -import io.element.android.libraries.matrixtest.core.A_ROOM_ID - -const val A_ROOM_NAME = "aRoomName" -const val A_MESSAGE = "Hello world!" -const val A_REPLY = "OK, I'll be there!" -const val ANOTHER_MESSAGE = "Hello universe!" +import io.element.android.libraries.matrixtest.A_MESSAGE +import io.element.android.libraries.matrixtest.A_ROOM_ID +import io.element.android.libraries.matrixtest.A_ROOM_NAME fun aRoomSummaryFilled( roomId: RoomId = A_ROOM_ID, diff --git a/libraries/matrixtest/src/main/kotlin/io/element/android/libraries/matrixtest/timeline/FakeMatrixTimeline.kt b/libraries/matrixtest/src/main/kotlin/io/element/android/libraries/matrixtest/timeline/FakeMatrixTimeline.kt index 417489dc83..c768a46ad7 100644 --- a/libraries/matrixtest/src/main/kotlin/io/element/android/libraries/matrixtest/timeline/FakeMatrixTimeline.kt +++ b/libraries/matrixtest/src/main/kotlin/io/element/android/libraries/matrixtest/timeline/FakeMatrixTimeline.kt @@ -24,11 +24,6 @@ import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.emptyFlow import org.matrix.rustcomponents.sdk.TimelineListener -const val A_SENDER_NAME = "Alice" -const val A_SENDER_ID = "@alice:server.org" -const val AN_EVENT_ID_VALUE = "!anEventId" -val AN_EVENT_ID = EventId(AN_EVENT_ID_VALUE) - class FakeMatrixTimeline : MatrixTimeline { override var callback: MatrixTimeline.Callback? = null From ce0ed5226dc15c32856a88bb3cc35c5dc5cbbf33 Mon Sep 17 00:00:00 2001 From: Benoit Marty Date: Thu, 9 Feb 2023 18:09:03 +0100 Subject: [PATCH 45/51] Exclude generated classes from code coverage metrics. --- build.gradle.kts | 18 ++++++++++++------ .../features/messages/timeline/TimelineView.kt | 5 ----- 2 files changed, 12 insertions(+), 11 deletions(-) diff --git a/build.gradle.kts b/build.gradle.kts index 53a7121cdf..73c043783c 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -165,12 +165,18 @@ koverMerged { classes { excludes.addAll( listOf( - /* - "*Fragment", - "*Fragment\$*", - "*Activity", - "*Activity\$*", - */ + // Exclude generated classes. + "*_ModuleKt", + "anvil.hint.binding.io.element.*", + "anvil.hint.merge.*", + "anvil.module.*", + "com.airbnb.android.showkase*", + "*_Factory*", + "*_Module*", + "*ComposableSingletons$*", + "*_AssistedFactory_Impl*", + "*BuildConfig", + // Other ) ) } diff --git a/features/messages/src/main/kotlin/io/element/android/features/messages/timeline/TimelineView.kt b/features/messages/src/main/kotlin/io/element/android/features/messages/timeline/TimelineView.kt index a6d6d8a2af..bc8a02c30b 100644 --- a/features/messages/src/main/kotlin/io/element/android/features/messages/timeline/TimelineView.kt +++ b/features/messages/src/main/kotlin/io/element/android/features/messages/timeline/TimelineView.kt @@ -352,11 +352,6 @@ internal fun TimelineLoadingMoreIndicator() { } } -class MessagesItemGroupPositionToMessagesTimelineItemContentProvider : - PairCombinedPreviewParameter( - TimelineItemGroupPositionProvider() to MessagesTimelineItemContentProvider() - ) - @Preview @Composable fun LoginRootScreenLightPreview( From e03ef8c3388d48f5ce48247f12858cc69bd9678a Mon Sep 17 00:00:00 2001 From: Benoit Marty Date: Thu, 9 Feb 2023 18:21:25 +0100 Subject: [PATCH 46/51] Rename classes (not a State) --- .../features/onboarding/OnBoardingScreen.kt | 8 ++++---- ...plashCarouselState.kt => SplashCarouselData.kt} | 2 +- ...tateFactory.kt => SplashCarouselDataFactory.kt} | 14 +++++++------- 3 files changed, 12 insertions(+), 12 deletions(-) rename features/onboarding/src/main/kotlin/io/element/android/features/onboarding/{SplashCarouselState.kt => SplashCarouselData.kt} (96%) rename features/onboarding/src/main/kotlin/io/element/android/features/onboarding/{SplashCarouselStateFactory.kt => SplashCarouselDataFactory.kt} (90%) diff --git a/features/onboarding/src/main/kotlin/io/element/android/features/onboarding/OnBoardingScreen.kt b/features/onboarding/src/main/kotlin/io/element/android/features/onboarding/OnBoardingScreen.kt index dc8362563e..b2699c1816 100644 --- a/features/onboarding/src/main/kotlin/io/element/android/features/onboarding/OnBoardingScreen.kt +++ b/features/onboarding/src/main/kotlin/io/element/android/features/onboarding/OnBoardingScreen.kt @@ -59,8 +59,8 @@ fun OnBoardingScreen( onSignUp: () -> Unit = {}, onSignIn: () -> Unit = {}, ) { - val carrouselState = remember { SplashCarouselStateFactory().create() } - val nbOfPages = carrouselState.items.size + val carrouselData = remember { SplashCarouselDataFactory().create() } + val nbOfPages = carrouselData.items.size var key by remember { mutableStateOf(false) } Box( modifier = modifier @@ -92,7 +92,7 @@ fun OnBoardingScreen( state = pagerState, ) { page -> // Our page content - OnBoardingPage(carrouselState.items[page]) + OnBoardingPage(carrouselData.items[page]) } HorizontalPagerIndicator( pagerState = pagerState, @@ -118,7 +118,7 @@ fun OnBoardingScreen( @Composable fun OnBoardingPage( - item: SplashCarouselState.Item, + item: SplashCarouselData.Item, modifier: Modifier = Modifier, ) { Box( diff --git a/features/onboarding/src/main/kotlin/io/element/android/features/onboarding/SplashCarouselState.kt b/features/onboarding/src/main/kotlin/io/element/android/features/onboarding/SplashCarouselData.kt similarity index 96% rename from features/onboarding/src/main/kotlin/io/element/android/features/onboarding/SplashCarouselState.kt rename to features/onboarding/src/main/kotlin/io/element/android/features/onboarding/SplashCarouselData.kt index f6523da7a6..58d1b6534c 100644 --- a/features/onboarding/src/main/kotlin/io/element/android/features/onboarding/SplashCarouselState.kt +++ b/features/onboarding/src/main/kotlin/io/element/android/features/onboarding/SplashCarouselData.kt @@ -19,7 +19,7 @@ package io.element.android.features.onboarding import androidx.annotation.DrawableRes import androidx.annotation.StringRes -data class SplashCarouselState( +data class SplashCarouselData( val items: List ) { data class Item( diff --git a/features/onboarding/src/main/kotlin/io/element/android/features/onboarding/SplashCarouselStateFactory.kt b/features/onboarding/src/main/kotlin/io/element/android/features/onboarding/SplashCarouselDataFactory.kt similarity index 90% rename from features/onboarding/src/main/kotlin/io/element/android/features/onboarding/SplashCarouselStateFactory.kt rename to features/onboarding/src/main/kotlin/io/element/android/features/onboarding/SplashCarouselDataFactory.kt index fc06ba49b6..e2848839ce 100644 --- a/features/onboarding/src/main/kotlin/io/element/android/features/onboarding/SplashCarouselStateFactory.kt +++ b/features/onboarding/src/main/kotlin/io/element/android/features/onboarding/SplashCarouselDataFactory.kt @@ -19,8 +19,8 @@ package io.element.android.features.onboarding import androidx.annotation.DrawableRes import io.element.android.libraries.ui.strings.R as StringR -class SplashCarouselStateFactory { - fun create(): SplashCarouselState { +class SplashCarouselDataFactory { + fun create(): SplashCarouselData { val lightTheme = true fun background(@DrawableRes lightDrawable: Int) = @@ -29,9 +29,9 @@ class SplashCarouselStateFactory { fun hero(@DrawableRes lightDrawable: Int, @DrawableRes darkDrawable: Int) = if (lightTheme) lightDrawable else darkDrawable - return SplashCarouselState( + return SplashCarouselData( listOf( - SplashCarouselState.Item( + SplashCarouselData.Item( StringR.string.ftue_auth_carousel_secure_title, StringR.string.ftue_auth_carousel_secure_body, hero( @@ -40,19 +40,19 @@ class SplashCarouselStateFactory { ), background(R.drawable.bg_carousel_page_1) ), - SplashCarouselState.Item( + SplashCarouselData.Item( StringR.string.ftue_auth_carousel_control_title, StringR.string.ftue_auth_carousel_control_body, hero(R.drawable.ic_splash_control, R.drawable.ic_splash_control_dark), background(R.drawable.bg_carousel_page_2) ), - SplashCarouselState.Item( + SplashCarouselData.Item( StringR.string.ftue_auth_carousel_encrypted_title, StringR.string.ftue_auth_carousel_encrypted_body, hero(R.drawable.ic_splash_secure, R.drawable.ic_splash_secure_dark), background(R.drawable.bg_carousel_page_3) ), - SplashCarouselState.Item( + SplashCarouselData.Item( collaborationTitle(), StringR.string.ftue_auth_carousel_workplace_body, hero( From f07bd1761ed9a35d871730c8351b13b0ab013d56 Mon Sep 17 00:00:00 2001 From: Benoit Marty Date: Thu, 9 Feb 2023 18:22:16 +0100 Subject: [PATCH 47/51] Add a test --- .../features/rageshake/bugreport/BugReportPresenterTest.kt | 1 + 1 file changed, 1 insertion(+) diff --git a/features/rageshake/src/test/kotlin/io/element/android/features/rageshake/bugreport/BugReportPresenterTest.kt b/features/rageshake/src/test/kotlin/io/element/android/features/rageshake/bugreport/BugReportPresenterTest.kt index 8737415d6c..bb367b15b3 100644 --- a/features/rageshake/src/test/kotlin/io/element/android/features/rageshake/bugreport/BugReportPresenterTest.kt +++ b/features/rageshake/src/test/kotlin/io/element/android/features/rageshake/bugreport/BugReportPresenterTest.kt @@ -190,6 +190,7 @@ class BugReportPresenterTest { val progressState = awaitItem() assertThat(progressState.sending).isEqualTo(Async.Loading(null)) assertThat(progressState.sendingProgress).isEqualTo(0f) + assertThat(progressState.submitEnabled).isFalse() assertThat(awaitItem().sendingProgress).isEqualTo(0.5f) assertThat(awaitItem().sendingProgress).isEqualTo(1f) skipItems(1) From 4b8c03fc3171538bcfaac9f5d536956fdf4702bc Mon Sep 17 00:00:00 2001 From: Benoit Marty Date: Thu, 9 Feb 2023 18:23:28 +0100 Subject: [PATCH 48/51] Add rule for minimum test coverage on States --- build.gradle.kts | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/build.gradle.kts b/build.gradle.kts index 73c043783c..af0cbaaa08 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -212,5 +212,18 @@ koverMerged { valueType = kotlinx.kover.api.VerificationValueType.COVERED_PERCENTAGE } } + // Rule to ensure that coverage of State is sufficient. + rule { + name = "Check code coverage of states" + target = kotlinx.kover.api.VerificationTarget.CLASS + overrideClassFilter { + includes += "*State" + } + bound { + minValue = 90 + counter = kotlinx.kover.api.CounterType.INSTRUCTION + valueType = kotlinx.kover.api.VerificationValueType.COVERED_PERCENTAGE + } + } } } From 173f768301a31955105c1a93e2ce81e2b5e92949 Mon Sep 17 00:00:00 2001 From: Benoit Marty Date: Thu, 9 Feb 2023 18:28:05 +0100 Subject: [PATCH 49/51] Exclude Node classes from code coverage metrics. --- build.gradle.kts | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/build.gradle.kts b/build.gradle.kts index af0cbaaa08..2ea2678c8b 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -171,12 +171,17 @@ koverMerged { "anvil.hint.merge.*", "anvil.module.*", "com.airbnb.android.showkase*", - "*_Factory*", - "*_Module*", + "*_Factory", + "*_Factory$*", + "*_Module", + "*_Module$*", "*ComposableSingletons$*", "*_AssistedFactory_Impl*", "*BuildConfig", // Other + // We do not cover Nodes (normally covered by maestro, but code coverage is not computed with maestro) + "*Node", + "*Node$*", ) ) } From 61693230439100dc1f7a9ba6dcc4cf20ee8b36b2 Mon Sep 17 00:00:00 2001 From: Benoit Marty Date: Thu, 9 Feb 2023 18:41:58 +0100 Subject: [PATCH 50/51] Move test data to TestData.kt --- .../kotlin/io/element/android/x/root/FakeBugReporter.kt | 5 ++--- .../android/features/login/root/LoginRootPresenterTest.kt | 6 +++--- .../features/logout/LogoutPreferencePresenterTest.kt | 6 +++--- .../features/rageshake/bugreport/BugReportPresenterTest.kt | 3 ++- .../android/features/rageshake/bugreport/FakeBugReporter.kt | 5 ++--- .../rageshake/detection/RageshakeDetectionPresenterTest.kt | 3 ++- .../android/features/roomlist/RoomListPresenterTests.kt | 5 +++-- .../io/element/android/libraries/matrixtest/TestData.kt | 5 ++++- 8 files changed, 21 insertions(+), 17 deletions(-) diff --git a/app/src/test/kotlin/io/element/android/x/root/FakeBugReporter.kt b/app/src/test/kotlin/io/element/android/x/root/FakeBugReporter.kt index f6cc6c8960..c6f7000cdf 100644 --- a/app/src/test/kotlin/io/element/android/x/root/FakeBugReporter.kt +++ b/app/src/test/kotlin/io/element/android/x/root/FakeBugReporter.kt @@ -19,12 +19,11 @@ package io.element.android.x.root import io.element.android.features.rageshake.reporter.BugReporter import io.element.android.features.rageshake.reporter.BugReporterListener import io.element.android.features.rageshake.reporter.ReportType +import io.element.android.libraries.matrixtest.A_FAILURE_REASON import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.delay import kotlinx.coroutines.launch -const val A_REASON = "There has been a failure" - // TODO Remove this duplicated class when we will rework modules. class FakeBugReporter(val mode: FakeBugReporterMode = FakeBugReporterMode.Success) : BugReporter { override fun sendBugReport( @@ -49,7 +48,7 @@ class FakeBugReporter(val mode: FakeBugReporterMode = FakeBugReporterMode.Succes when (mode) { FakeBugReporterMode.Success -> Unit FakeBugReporterMode.Failure -> { - listener?.onUploadFailed(A_REASON) + listener?.onUploadFailed(A_FAILURE_REASON) return@launch } FakeBugReporterMode.Cancel -> { diff --git a/features/login/src/test/kotlin/io/element/android/features/login/root/LoginRootPresenterTest.kt b/features/login/src/test/kotlin/io/element/android/features/login/root/LoginRootPresenterTest.kt index f9eed8d66f..3fa20ae93a 100644 --- a/features/login/src/test/kotlin/io/element/android/features/login/root/LoginRootPresenterTest.kt +++ b/features/login/src/test/kotlin/io/element/android/features/login/root/LoginRootPresenterTest.kt @@ -23,11 +23,11 @@ import app.cash.molecule.moleculeFlow import app.cash.turbine.test import com.google.common.truth.Truth.assertThat import io.element.android.libraries.matrix.core.SessionId -import io.element.android.libraries.matrixtest.A_FAILURE import io.element.android.libraries.matrixtest.A_HOMESERVER import io.element.android.libraries.matrixtest.A_HOMESERVER_2 import io.element.android.libraries.matrixtest.A_PASSWORD import io.element.android.libraries.matrixtest.A_SESSION_ID +import io.element.android.libraries.matrixtest.A_THROWABLE import io.element.android.libraries.matrixtest.A_USER_NAME import io.element.android.libraries.matrixtest.auth.FakeAuthenticationService import kotlinx.coroutines.ExperimentalCoroutinesApi @@ -106,12 +106,12 @@ class LoginRootPresenterTest { initialState.eventSink.invoke(LoginRootEvents.SetPassword(A_PASSWORD)) skipItems(1) val loginAndPasswordState = awaitItem() - authenticationService.givenLoginError(A_FAILURE) + authenticationService.givenLoginError(A_THROWABLE) loginAndPasswordState.eventSink.invoke(LoginRootEvents.Submit) val submitState = awaitItem() assertThat(submitState.loggedInState).isEqualTo(LoggedInState.LoggingIn) val loggedInState = awaitItem() - assertThat(loggedInState.loggedInState).isEqualTo(LoggedInState.ErrorLoggingIn(A_FAILURE)) + assertThat(loggedInState.loggedInState).isEqualTo(LoggedInState.ErrorLoggingIn(A_THROWABLE)) } } diff --git a/features/logout/src/test/kotlin/io/element/android/features/logout/LogoutPreferencePresenterTest.kt b/features/logout/src/test/kotlin/io/element/android/features/logout/LogoutPreferencePresenterTest.kt index 5367c21e4d..e84226b3e8 100644 --- a/features/logout/src/test/kotlin/io/element/android/features/logout/LogoutPreferencePresenterTest.kt +++ b/features/logout/src/test/kotlin/io/element/android/features/logout/LogoutPreferencePresenterTest.kt @@ -24,7 +24,7 @@ import app.cash.turbine.test import com.google.common.truth.Truth.assertThat import io.element.android.libraries.architecture.Async import io.element.android.libraries.matrix.core.SessionId -import io.element.android.libraries.matrixtest.A_FAILURE +import io.element.android.libraries.matrixtest.A_THROWABLE import io.element.android.libraries.matrixtest.FakeMatrixClient import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.test.runTest @@ -71,12 +71,12 @@ class LogoutPreferencePresenterTest { presenter.present() }.test { val initialState = awaitItem() - matrixClient.givenLogoutError(A_FAILURE) + matrixClient.givenLogoutError(A_THROWABLE) initialState.eventSink.invoke(LogoutPreferenceEvents.Logout) val loadingState = awaitItem() assertThat(loadingState.logoutAction).isInstanceOf(Async.Loading::class.java) val successState = awaitItem() - assertThat(successState.logoutAction).isEqualTo(Async.Failure(A_FAILURE)) + assertThat(successState.logoutAction).isEqualTo(Async.Failure(A_THROWABLE)) } } } diff --git a/features/rageshake/src/test/kotlin/io/element/android/features/rageshake/bugreport/BugReportPresenterTest.kt b/features/rageshake/src/test/kotlin/io/element/android/features/rageshake/bugreport/BugReportPresenterTest.kt index bb367b15b3..484c3bd1b0 100644 --- a/features/rageshake/src/test/kotlin/io/element/android/features/rageshake/bugreport/BugReportPresenterTest.kt +++ b/features/rageshake/src/test/kotlin/io/element/android/features/rageshake/bugreport/BugReportPresenterTest.kt @@ -25,6 +25,7 @@ import com.google.common.truth.Truth.assertThat import io.element.android.features.rageshake.crash.ui.A_CRASH_DATA import io.element.android.features.rageshake.crash.ui.FakeCrashDataStore import io.element.android.libraries.architecture.Async +import io.element.android.libraries.matrixtest.A_FAILURE_REASON import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.test.runTest import org.junit.Test @@ -218,7 +219,7 @@ class BugReportPresenterTest { assertThat(awaitItem().sendingProgress).isEqualTo(0.5f) // Failure assertThat(awaitItem().sendingProgress).isEqualTo(0f) - assertThat((awaitItem().sending as Async.Failure).error.message).isEqualTo(A_REASON) + assertThat((awaitItem().sending as Async.Failure).error.message).isEqualTo(A_FAILURE_REASON) } } diff --git a/features/rageshake/src/test/kotlin/io/element/android/features/rageshake/bugreport/FakeBugReporter.kt b/features/rageshake/src/test/kotlin/io/element/android/features/rageshake/bugreport/FakeBugReporter.kt index a1a2c613a7..29977d7a95 100644 --- a/features/rageshake/src/test/kotlin/io/element/android/features/rageshake/bugreport/FakeBugReporter.kt +++ b/features/rageshake/src/test/kotlin/io/element/android/features/rageshake/bugreport/FakeBugReporter.kt @@ -19,12 +19,11 @@ package io.element.android.features.rageshake.bugreport import io.element.android.features.rageshake.reporter.BugReporter import io.element.android.features.rageshake.reporter.BugReporterListener import io.element.android.features.rageshake.reporter.ReportType +import io.element.android.libraries.matrixtest.A_FAILURE_REASON import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.delay import kotlinx.coroutines.launch -const val A_REASON = "There has been a failure" - class FakeBugReporter(val mode: FakeBugReporterMode = FakeBugReporterMode.Success) : BugReporter { override fun sendBugReport( coroutineScope: CoroutineScope, @@ -48,7 +47,7 @@ class FakeBugReporter(val mode: FakeBugReporterMode = FakeBugReporterMode.Succes when (mode) { FakeBugReporterMode.Success -> Unit FakeBugReporterMode.Failure -> { - listener?.onUploadFailed(A_REASON) + listener?.onUploadFailed(A_FAILURE_REASON) return@launch } FakeBugReporterMode.Cancel -> { diff --git a/features/rageshake/src/test/kotlin/io/element/android/features/rageshake/detection/RageshakeDetectionPresenterTest.kt b/features/rageshake/src/test/kotlin/io/element/android/features/rageshake/detection/RageshakeDetectionPresenterTest.kt index f5fb8cbedc..1ef8941bff 100644 --- a/features/rageshake/src/test/kotlin/io/element/android/features/rageshake/detection/RageshakeDetectionPresenterTest.kt +++ b/features/rageshake/src/test/kotlin/io/element/android/features/rageshake/detection/RageshakeDetectionPresenterTest.kt @@ -28,6 +28,7 @@ import io.element.android.features.rageshake.preferences.FakeRageShake import io.element.android.features.rageshake.preferences.FakeRageshakeDataStore import io.element.android.features.rageshake.preferences.RageshakePreferencesPresenter import io.element.android.features.rageshake.screenshot.ImageResult +import io.element.android.libraries.matrixtest.AN_EXCEPTION import io.mockk.mockk import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.flow.first @@ -142,7 +143,7 @@ class RageshakeDetectionPresenterTest { rageshake.triggerPhoneRageshake() assertThat(awaitItem().takeScreenshot).isTrue() initialState.eventSink.invoke( - RageshakeDetectionEvents.ProcessScreenshot(ImageResult.Error(Exception("Error"))) + RageshakeDetectionEvents.ProcessScreenshot(ImageResult.Error(AN_EXCEPTION)) ) assertThat(awaitItem().showDialog).isTrue() initialState.eventSink.invoke(RageshakeDetectionEvents.Dismiss) diff --git a/features/roomlist/src/test/kotlin/io/element/android/features/roomlist/RoomListPresenterTests.kt b/features/roomlist/src/test/kotlin/io/element/android/features/roomlist/RoomListPresenterTests.kt index e15c08fecc..94256c8888 100644 --- a/features/roomlist/src/test/kotlin/io/element/android/features/roomlist/RoomListPresenterTests.kt +++ b/features/roomlist/src/test/kotlin/io/element/android/features/roomlist/RoomListPresenterTests.kt @@ -28,6 +28,7 @@ import io.element.android.libraries.dateformatter.LastMessageFormatter import io.element.android.libraries.designsystem.components.avatar.AvatarData import io.element.android.libraries.matrix.core.SessionId import io.element.android.libraries.matrixtest.AN_AVATAR_URL +import io.element.android.libraries.matrixtest.AN_EXCEPTION import io.element.android.libraries.matrixtest.A_MESSAGE import io.element.android.libraries.matrixtest.A_ROOM_ID import io.element.android.libraries.matrixtest.A_ROOM_NAME @@ -69,8 +70,8 @@ class RoomListPresenterTests { val presenter = RoomListPresenter( FakeMatrixClient( SessionId("sessionId"), - userDisplayName = Result.failure(Exception("Error")), - userAvatarURLString = Result.failure(Exception("Error")), + userDisplayName = Result.failure(AN_EXCEPTION), + userAvatarURLString = Result.failure(AN_EXCEPTION), ), createDateFormatter() ) diff --git a/libraries/matrixtest/src/main/kotlin/io/element/android/libraries/matrixtest/TestData.kt b/libraries/matrixtest/src/main/kotlin/io/element/android/libraries/matrixtest/TestData.kt index cd186c50c2..970a3882ab 100644 --- a/libraries/matrixtest/src/main/kotlin/io/element/android/libraries/matrixtest/TestData.kt +++ b/libraries/matrixtest/src/main/kotlin/io/element/android/libraries/matrixtest/TestData.kt @@ -38,4 +38,7 @@ const val A_SESSION_ID = "sessionId" const val AN_AVATAR_URL = "mxc://data" -val A_FAILURE = Throwable("error") +const val A_FAILURE_REASON = "There has been a failure" +val A_THROWABLE = Throwable(A_FAILURE_REASON) +val AN_EXCEPTION = Exception(A_FAILURE_REASON) + From beb9df262d7dbfdc2b445b34fe99dbc12a0dbfa0 Mon Sep 17 00:00:00 2001 From: Benoit Marty Date: Thu, 9 Feb 2023 18:43:17 +0100 Subject: [PATCH 51/51] Global coverage is now 45.7 :rocket: --- build.gradle.kts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/build.gradle.kts b/build.gradle.kts index 2ea2678c8b..0869ecc6ca 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -196,9 +196,9 @@ koverMerged { name = "Global minimum code coverage." target = kotlinx.kover.api.VerificationTarget.ALL bound { - minValue = 40 + minValue = 45 // Setting a max value, so that if coverage is bigger, it means that we have to change minValue. - maxValue = 45 + maxValue = 50 counter = kotlinx.kover.api.CounterType.LINE valueType = kotlinx.kover.api.VerificationValueType.COVERED_PERCENTAGE }