diff --git a/.github/workflows/quality.yml b/.github/workflows/quality.yml index 78c407cde1..93a2b8c3f4 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: | + **/kover/merged/verification/errors.txt - name: Prepare Danger if: always() run: | diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index f4a12c9ec2..19b3192e68 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 @@ -39,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/ 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/main/kotlin/io/element/android/x/di/AppModule.kt b/app/src/main/kotlin/io/element/android/x/di/AppModule.kt index 7cb3fb55c3..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,6 +20,8 @@ import android.content.Context import com.squareup.anvil.annotations.ContributesTo import dagger.Module import dagger.Provides +import io.element.android.features.rageshake.reporter.BugReporter +import io.element.android.features.rageshake.reporter.DefaultBugReporter import io.element.android.libraries.core.coroutine.CoroutineDispatchers import io.element.android.libraries.di.AppScope import io.element.android.libraries.di.ApplicationContext @@ -58,4 +60,7 @@ object AppModule { diffUpdateDispatcher = Executors.newSingleThreadExecutor().asCoroutineDispatcher() ) } + + @Provides + fun providesBugReporter(bugReporter: DefaultBugReporter): BugReporter = bugReporter } 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..c6f7000cdf --- /dev/null +++ b/app/src/test/kotlin/io/element/android/x/root/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.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 + +// 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_FAILURE_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, + ) + } +} diff --git a/build.gradle.kts b/build.gradle.kts index f2f16714ea..0869ecc6ca 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 { @@ -164,14 +165,70 @@ 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", + "*_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$*", ) ) } } + + // 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 = 45 + // Setting a max value, so that if coverage is bigger, it means that we have to change minValue. + maxValue = 50 + 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" + excludes += "*TemplatePresenter" + } + bound { + minValue = 90 + counter = kotlinx.kover.api.CounterType.INSTRUCTION + 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 + } + } + } } 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..b22c03dc35 --- /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.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/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..3fa20ae93a --- /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.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 +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_USER_NAME)) + val loginState = awaitItem() + 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_USER_NAME, 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_USER_NAME)) + 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_USER_NAME)) + initialState.eventSink.invoke(LoginRootEvents.SetPassword(A_PASSWORD)) + skipItems(1) + val loginAndPasswordState = awaitItem() + 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_THROWABLE)) + } + } + + @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/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..e84226b3e8 --- /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.A_THROWABLE +import io.element.android.libraries.matrixtest.FakeMatrixClient +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_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_THROWABLE)) + } + } +} + 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/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/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( 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..a16d9f7eb1 --- /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.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.room.FakeMatrixRoom +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() + 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() + ): 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_USER_ID.value, + senderDisplayName = A_USER_NAME, + senderAvatar = AvatarData(), + content = content, + sentTime = "", + isMine = isMine, + reactionsState = TimelineItemReactions(persistentListOf()) +) 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..06d1486293 --- /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.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 +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_USER_ID.value, + senderDisplayName = A_USER_NAME, + senderAvatar = AvatarData(), + content = content, + sentTime = "", + isMine = isMine, + reactionsState = TimelineItemReactions(persistentListOf()) +) 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..8278acd13e --- /dev/null +++ b/features/messages/src/test/kotlin/io/element/android/features/messages/textcomposer/MessageComposerPresenterTest.kt @@ -0,0 +1,252 @@ +/* + * 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.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.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() + ) + 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() + ) + 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() + ) + 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() + ) + 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() + ) + 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() + ) + 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() + ) + 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() + } + } + + @Test + fun `present - edit message`() = runTest { + val fakeMatrixRoom = FakeMatrixRoom() + 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() + 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)) + val 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) +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/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..ee73dc260b --- /dev/null +++ b/features/messages/src/test/kotlin/io/element/android/features/messages/timeline/TimelinePresenterTest.kt @@ -0,0 +1,116 @@ +/* + * 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.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.room.FakeMatrixRoom +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() + ) + moleculeFlow(RecompositionClock.Immediate) { + presenter.present() + }.test { + val initialState = awaitItem() + assertThat(initialState.timelineItems).isEmpty() + } + } + + @Test + fun `present - load more`() = runTest { + val matrixTimeline = FakeMatrixTimeline() + val matrixRoom = FakeMatrixRoom(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(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() + } + } + + @Test + fun `present - test callback`() = runTest { + val matrixTimeline = FakeMatrixTimeline() + val matrixRoom = FakeMatrixRoom(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/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( 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/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) + } + } +} diff --git a/features/rageshake/build.gradle.kts b/features/rageshake/build.gradle.kts index ecb333289b..45cc9cdd64 100644 --- a/features/rageshake/build.gradle.kts +++ b/features/rageshake/build.gradle.kts @@ -40,11 +40,19 @@ 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) 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) + testImplementation(libs.test.mockk) + androidTestImplementation(libs.test.junitext) } 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..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,10 +23,10 @@ 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 +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 +45,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 @@ -72,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 @@ -126,7 +126,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, @@ -145,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/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 /** 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/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/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/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/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)?) } 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() } 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..cfc0c68561 --- /dev/null +++ b/features/rageshake/src/main/kotlin/io/element/android/features/rageshake/reporter/DefaultBugReporter.kt @@ -0,0 +1,525 @@ +/* + * 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 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 +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 && vectorFileLogger != null) { + 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.getFileUri() + ?.toUri() + ?.toFile() + ?.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") + } + } +} 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..31eeac39a6 --- /dev/null +++ b/features/rageshake/src/main/kotlin/io/element/android/features/rageshake/screenshot/DefaultScreenshotHolder.kt @@ -0,0 +1,52 @@ +/* + * 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 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 +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 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 33674c07fb..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 @@ -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,10 @@ 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 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..484c3bd1b0 --- /dev/null +++ b/features/rageshake/src/test/kotlin/io/element/android/features/rageshake/bugreport/BugReportPresenterTest.kt @@ -0,0 +1,249 @@ +/* + * 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 io.element.android.libraries.matrixtest.A_FAILURE_REASON +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(progressState.submitEnabled).isFalse() + 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_FAILURE_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..29977d7a95 --- /dev/null +++ b/features/rageshake/src/test/kotlin/io/element/android/features/rageshake/bugreport/FakeBugReporter.kt @@ -0,0 +1,69 @@ +/* + * 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 io.element.android.libraries.matrixtest.A_FAILURE_REASON +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.delay +import kotlinx.coroutines.launch + +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_FAILURE_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 +} 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 = "" + } +} 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..1ef8941bff --- /dev/null +++ b/features/rageshake/src/test/kotlin/io/element/android/features/rageshake/detection/RageshakeDetectionPresenterTest.kt @@ -0,0 +1,192 @@ +/* + * 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 android.graphics.Bitmap +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 io.element.android.libraries.matrixtest.AN_EXCEPTION +import io.mockk.mockk +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 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) + 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(AN_EXCEPTION)) + ) + 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.Success(aBitmap())) + ) + assertThat(awaitItem().showDialog).isTrue() + initialState.eventSink.invoke(RageshakeDetectionEvents.Disable) + skipItems(1) + assertThat(awaitItem().showDialog).isFalse() + assertThat(rageshakeDataStore.isEnabled().first()).isFalse() + } + } +} + +private fun aBitmap(): Bitmap = mockk() + 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 new file mode 100644 index 0000000000..fbabdaac5d --- /dev/null +++ b/features/rageshake/src/test/kotlin/io/element/android/features/rageshake/preferences/FakeRageShake.kt @@ -0,0 +1,45 @@ +/* + * 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.RageShake + +const val A_SENSITIVITY = 1f + +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/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) + } + } +} + diff --git a/features/roomlist/build.gradle.kts b/features/roomlist/build.gradle.kts index fc0402eafc..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) @@ -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/main/kotlin/io/element/android/features/roomlist/RoomListPresenter.kt b/features/roomlist/src/main/kotlin/io/element/android/features/roomlist/RoomListPresenter.kt index b28504eee1..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 @@ -57,7 +58,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 +86,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/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")) } } } 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/rageshake/src/test/kotlin/io/element/android/features/login/ExampleUnitTest.kt b/features/roomlist/src/test/kotlin/io/element/android/features/roomlist/FakeLastMessageFormatter.kt similarity index 59% rename from features/rageshake/src/test/kotlin/io/element/android/features/login/ExampleUnitTest.kt rename to features/roomlist/src/test/kotlin/io/element/android/features/roomlist/FakeLastMessageFormatter.kt index ee6363e624..997846056a 100644 --- a/features/rageshake/src/test/kotlin/io/element/android/features/login/ExampleUnitTest.kt +++ b/features/roomlist/src/test/kotlin/io/element/android/features/roomlist/FakeLastMessageFormatter.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,17 @@ * limitations under the License. */ -package io.element.android.features.login +package io.element.android.features.roomlist -import org.junit.Assert.assertEquals -import org.junit.Test +import io.element.android.libraries.dateformatter.LastMessageFormatter -/** - * 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) +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 615353b7e8..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 @@ -14,14 +14,30 @@ * limitations under the License. */ +@file:OptIn(ExperimentalCoroutinesApi::class) + package io.element.android.features.roomlist 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.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.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 +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.room.FakeRoomSummaryDataSource +import io.element.android.libraries.matrixtest.room.aRoomSummaryFilled +import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.test.runTest import org.junit.Test @@ -29,11 +45,11 @@ class RoomListPresenterTests { @Test fun `present - should start with no user and then load user with success`() = runTest { - val presenter = RoomListPresenter( FakeMatrixClient( SessionId("sessionId") - ), LastMessageFormatter() + ), + createDateFormatter() ) moleculeFlow(RecompositionClock.Immediate) { presenter.present() @@ -41,7 +57,166 @@ class RoomListPresenterTests { val initialState = awaitItem() assertThat(initialState.matrixUser).isNull() val withUserState = awaitItem() - assertThat(withUserState).isNotNull() + 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(AN_EXCEPTION), + userAvatarURLString = Result.failure(AN_EXCEPTION), + ), + 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) + } + } + + @Test + fun `present - should filter room with success`() = runTest { + val presenter = RoomListPresenter( + FakeMatrixClient( + SessionId("sessionId") + ), + createDateFormatter() + ) + moleculeFlow(RecompositionClock.Immediate) { + presenter.present() + }.test { + skipItems(1) + val withUserState = awaitItem() + assertThat(withUserState.filter).isEqualTo("") + withUserState.eventSink.invoke(RoomListEvents.UpdateFilter("t")) + val withFilterState = awaitItem() + assertThat(withFilterState.filter).isEqualTo("t") + } + } + + @Test + fun `present - load 1 room with success`() = runTest { + val roomSummaryDataSource = FakeRoomSummaryDataSource() + val presenter = RoomListPresenter( + FakeMatrixClient( + sessionId = SessionId("sessionId"), + roomSummaryDataSource = roomSummaryDataSource + ), + createDateFormatter() + ) + 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() + roomSummaryDataSource.postRoomSummary(listOf(aRoomSummaryFilled())) + skipItems(1) + val withRoomState = awaitItem() + assertThat(withRoomState.roomList.size).isEqualTo(1) + assertThat(withRoomState.roomList.first()).isEqualTo(aRoomListRoomSummary) + } + } + + @Test + fun `present - load 1 room with success and filter rooms`() = runTest { + val roomSummaryDataSource = FakeRoomSummaryDataSource() + val presenter = RoomListPresenter( + FakeMatrixClient( + sessionId = SessionId("sessionId"), + roomSummaryDataSource = roomSummaryDataSource + ), + createDateFormatter() + ) + 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).isEmpty() + } + } + + @Test + fun `present - update visible range`() = runTest { + val roomSummaryDataSource = FakeRoomSummaryDataSource() + val presenter = RoomListPresenter( + FakeMatrixClient( + sessionId = SessionId("sessionId"), + roomSummaryDataSource = roomSummaryDataSource + ), + createDateFormatter() + ) + 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 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 = A_FORMATTED_DATE, + lastMessage = A_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..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 @@ -14,27 +14,39 @@ * 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 com.google.common.truth.Truth.assertThat +import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.test.runTest 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) + } } } 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/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" 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/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/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..60ecd95052 --- /dev/null +++ b/libraries/dateformatter/build.gradle.kts @@ -0,0 +1,43 @@ +/* + * 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) + api(libs.datetime) + ksp(libs.showkase.processor) + + testImplementation(libs.test.junit) + testImplementation(libs.test.truth) + } +} 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/features/preferences/src/test/kotlin/io/element/android/features/preferences/ExampleUnitTest.kt b/libraries/dateformatter/src/main/kotlin/io/element/android/libraries/dateformatter/LastMessageFormatter.kt similarity index 58% rename from features/preferences/src/test/kotlin/io/element/android/features/preferences/ExampleUnitTest.kt rename to libraries/dateformatter/src/main/kotlin/io/element/android/libraries/dateformatter/LastMessageFormatter.kt index 3b615c83e9..caa5886cf9 100644 --- a/features/preferences/src/test/kotlin/io/element/android/features/preferences/ExampleUnitTest.kt +++ b/libraries/dateformatter/src/main/kotlin/io/element/android/libraries/dateformatter/LastMessageFormatter.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,8 @@ * limitations under the License. */ -package io.element.android.features.preferences +package io.element.android.libraries.dateformatter -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) - } +interface LastMessageFormatter { + fun format(timestamp: Long?): String } 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..feab851a8b --- /dev/null +++ b/libraries/dateformatter/src/main/kotlin/io/element/android/libraries/dateformatter/di/DateFormatterModule.kt @@ -0,0 +1,38 @@ +/* + * 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 kotlinx.datetime.TimeZone +import java.util.* + +@Module +@ContributesTo(AppScope::class) +object DateFormatterModule { + @Provides + fun providesClock(): Clock = Clock.System + + @Provides + fun providesLocale(): Locale = Locale.getDefault() + + @Provides + fun providesTimezone(): TimeZone = TimeZone.currentSystemDefault() +} 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 79% 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..a466491766 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,32 +35,33 @@ import java.util.Locale import javax.inject.Inject import kotlin.math.absoluteValue -class LastMessageFormatter @Inject constructor() { - - private val clock: Clock = Clock.System - private val locale: Locale = Locale.getDefault() - +@ContributesBinding(AppScope::class) +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") + 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) } - 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) - 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 -> { @@ -77,7 +81,7 @@ class LastMessageFormatter @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) } @@ -97,6 +101,6 @@ class LastMessageFormatter @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..c21dcf4230 --- /dev/null +++ b/libraries/dateformatter/src/test/kotlin/io/element/android/libraries/dateformatter/impl/DefaultLastMessageFormatterTest.kt @@ -0,0 +1,106 @@ +/* + * 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 kotlinx.datetime.TimeZone +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("18: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("18: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("18: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("17: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, TimeZone.UTC) + } +} diff --git a/features/messages/src/test/kotlin/io/element/android/features/messages/ExampleUnitTest.kt b/libraries/dateformatter/src/test/kotlin/io/element/android/libraries/dateformatter/impl/FakeClock.kt similarity index 59% rename from features/messages/src/test/kotlin/io/element/android/features/messages/ExampleUnitTest.kt rename to libraries/dateformatter/src/test/kotlin/io/element/android/libraries/dateformatter/impl/FakeClock.kt index 83296930a7..58a5495218 100644 --- a/features/messages/src/test/kotlin/io/element/android/features/messages/ExampleUnitTest.kt +++ b/libraries/dateformatter/src/test/kotlin/io/element/android/libraries/dateformatter/impl/FakeClock.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,17 @@ * limitations under the License. */ -package io.element.android.features.messages +package io.element.android.libraries.dateformatter.impl -import org.junit.Assert.assertEquals -import org.junit.Test +import kotlinx.datetime.Clock +import kotlinx.datetime.Instant -/** - * 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) +class FakeClock : Clock { + private var instant: Instant = Instant.fromEpochMilliseconds(0) + + fun givenInstant(instant: Instant) { + this.instant = instant } + + override fun now(): Instant = instant } 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..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 @@ -25,10 +25,18 @@ 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 kotlinx.coroutines.delay import org.matrix.rustcomponents.sdk.MediaSource -class FakeMatrixClient(override val sessionId: SessionId) : MatrixClient { +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 { + + private var logoutFailure: Throwable? = null override fun getRoom(roomId: RoomId): MatrixRoom? { return FakeMatrixRoom(roomId) @@ -39,23 +47,30 @@ class FakeMatrixClient(override val sessionId: SessionId) : MatrixClient { override fun stopSync() = Unit override fun roomSummaryDataSource(): RoomSummaryDataSource { - return InMemoryRoomSummaryDataSource() + return roomSummaryDataSource } override fun mediaResolver(): MediaResolver { return FakeMediaResolver() } - override suspend fun logout() = Unit + fun givenLogoutError(failure: Throwable) { + logoutFailure = failure + } - override fun userId(): UserId = UserId("") + override suspend fun logout() { + delay(100) + logoutFailure?.let { throw it } + } + + 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..970a3882ab --- /dev/null +++ b/libraries/matrixtest/src/main/kotlin/io/element/android/libraries/matrixtest/TestData.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.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" + +const val A_FAILURE_REASON = "There has been a failure" +val A_THROWABLE = Throwable(A_FAILURE_REASON) +val AN_EXCEPTION = Exception(A_FAILURE_REASON) + 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..ab4935ede3 --- /dev/null +++ b/libraries/matrixtest/src/main/kotlin/io/element/android/libraries/matrixtest/auth/FakeAuthenticationService.kt @@ -0,0 +1,69 @@ +/* + * 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 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 + +class FakeAuthenticationService : MatrixAuthenticationService { + private var homeserver: String = A_HOMESERVER + private var loginError: Throwable? = null + + 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 + } + + fun givenHomeserver(homeserver: String) { + this.homeserver = homeserver + } + + override fun getHomeserverOrDefault(): String { + return homeserver + } + + override suspend fun setHomeserver(homeserver: String) { + delay(100) + } + + override suspend fun login(username: String, password: String): SessionId { + delay(100) + loginError?.let { throw it } + return SessionId(A_SESSION_ID) + } + + fun givenLoginError(throwable: Throwable?) { + loginError = throwable + } +} 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..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,17 +20,20 @@ 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 = "", 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 +41,7 @@ class FakeMatrixRoom( } override fun timeline(): MatrixTimeline { - return FakeMatrixTimeline() + return matrixTimeline } override suspend fun userDisplayName(userId: String): Result { @@ -50,18 +53,34 @@ class FakeMatrixRoom( } override suspend fun sendMessage(message: String): Result { - TODO("Not yet implemented") + delay(100) + 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) } + 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) } } 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 66% 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 5179e911ab..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,11 +21,22 @@ import io.element.android.libraries.matrix.room.RoomSummaryDataSource import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow -class InMemoryRoomSummaryDataSource : RoomSummaryDataSource { +class FakeRoomSummaryDataSource : RoomSummaryDataSource { - override fun roomSummaries(): StateFlow> { - return MutableStateFlow(emptyList()) + private val roomSummariesFlow = MutableStateFlow>(emptyList()) + + suspend fun postRoomSummary(roomSummaries: List) { + roomSummariesFlow.emit(roomSummaries) } - override fun setSlidingSyncRange(range: IntRange) = Unit + override fun roomSummaries(): StateFlow> { + return roomSummariesFlow + } + + var latestSlidingSyncRange: IntRange? = null + private set + + override fun setSlidingSyncRange(range: IntRange) { + latestSlidingSyncRange = range + } } 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..41d9f6d524 --- /dev/null +++ b/libraries/matrixtest/src/main/kotlin/io/element/android/libraries/matrixtest/room/RoomSummaryFixture.kt @@ -0,0 +1,62 @@ +/* + * 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.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, + name: String = A_ROOM_NAME, + isDirect: Boolean = false, + avatarURLString: String? = null, + lastMessage: CharSequence? = A_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_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/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..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 @@ -19,24 +19,29 @@ 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 class FakeMatrixTimeline : MatrixTimeline { + override var callback: MatrixTimeline.Callback? = null - 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) } 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 } } 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")