commit
78a83c0592
87 changed files with 4149 additions and 800 deletions
9
.github/workflows/quality.yml
vendored
9
.github/workflows/quality.yml
vendored
|
|
@ -29,6 +29,15 @@ jobs:
|
||||||
name: linting-report
|
name: linting-report
|
||||||
path: |
|
path: |
|
||||||
*/build/reports/**/*.*
|
*/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
|
- name: Prepare Danger
|
||||||
if: always()
|
if: always()
|
||||||
run: |
|
run: |
|
||||||
|
|
|
||||||
7
.github/workflows/tests.yml
vendored
7
.github/workflows/tests.yml
vendored
|
|
@ -21,10 +21,7 @@ jobs:
|
||||||
cancel-in-progress: true
|
cancel-in-progress: true
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v3
|
- uses: actions/checkout@v3
|
||||||
- name: Run tests
|
- name: Run tests and generate kover report
|
||||||
run: ./gradlew test $CI_GRADLE_ARG_PROPERTIES
|
|
||||||
- name: Generate kover report
|
|
||||||
if: always()
|
|
||||||
run: ./gradlew koverMergedReport $CI_GRADLE_ARG_PROPERTIES
|
run: ./gradlew koverMergedReport $CI_GRADLE_ARG_PROPERTIES
|
||||||
|
|
||||||
- name: Archive kover report
|
- name: Archive kover report
|
||||||
|
|
@ -39,7 +36,7 @@ jobs:
|
||||||
if: failure()
|
if: failure()
|
||||||
uses: actions/upload-artifact@v3
|
uses: actions/upload-artifact@v3
|
||||||
with:
|
with:
|
||||||
name: screenshot-results
|
name: tests-and-screenshot-tests-results
|
||||||
path: |
|
path: |
|
||||||
**/out/failures/
|
**/out/failures/
|
||||||
**/build/reports/tests/*UnitTest/
|
**/build/reports/tests/*UnitTest/
|
||||||
|
|
|
||||||
|
|
@ -178,4 +178,11 @@ dependencies {
|
||||||
|
|
||||||
implementation(libs.dagger)
|
implementation(libs.dagger)
|
||||||
kapt(libs.dagger.compiler)
|
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)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -20,6 +20,8 @@ import android.content.Context
|
||||||
import com.squareup.anvil.annotations.ContributesTo
|
import com.squareup.anvil.annotations.ContributesTo
|
||||||
import dagger.Module
|
import dagger.Module
|
||||||
import dagger.Provides
|
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.core.coroutine.CoroutineDispatchers
|
||||||
import io.element.android.libraries.di.AppScope
|
import io.element.android.libraries.di.AppScope
|
||||||
import io.element.android.libraries.di.ApplicationContext
|
import io.element.android.libraries.di.ApplicationContext
|
||||||
|
|
@ -58,4 +60,7 @@ object AppModule {
|
||||||
diffUpdateDispatcher = Executors.newSingleThreadExecutor().asCoroutineDispatcher()
|
diffUpdateDispatcher = Executors.newSingleThreadExecutor().asCoroutineDispatcher()
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Provides
|
||||||
|
fun providesBugReporter(bugReporter: DefaultBugReporter): BugReporter = bugReporter
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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<String, String>?,
|
||||||
|
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
|
||||||
|
}
|
||||||
|
|
@ -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<Boolean> = appHasCrashedFlow
|
||||||
|
|
||||||
|
override fun crashInfo(): Flow<String> = crashDataFlow
|
||||||
|
|
||||||
|
override suspend fun reset() {
|
||||||
|
appHasCrashedFlow.value = false
|
||||||
|
crashDataFlow.value = ""
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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()
|
||||||
|
}
|
||||||
|
|
@ -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<Boolean> = isEnabledFlow
|
||||||
|
|
||||||
|
override suspend fun setIsEnabled(isEnabled: Boolean) {
|
||||||
|
isEnabledFlow.value = isEnabled
|
||||||
|
}
|
||||||
|
|
||||||
|
private val sensitivityFlow = MutableStateFlow(sensitivity)
|
||||||
|
override fun sensitivity(): Flow<Float> = sensitivityFlow
|
||||||
|
|
||||||
|
override suspend fun setSensitivity(sensitivity: Float) {
|
||||||
|
sensitivityFlow.value = sensitivity
|
||||||
|
}
|
||||||
|
|
||||||
|
override suspend fun reset() = Unit
|
||||||
|
}
|
||||||
|
|
@ -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
|
||||||
|
}
|
||||||
|
|
@ -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,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -155,6 +155,7 @@ allprojects {
|
||||||
apply(plugin = "kover")
|
apply(plugin = "kover")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// https://kotlin.github.io/kotlinx-kover/
|
||||||
// Run `./gradlew koverMergedHtmlReport` to get report at ./build/reports/kover
|
// Run `./gradlew koverMergedHtmlReport` to get report at ./build/reports/kover
|
||||||
// Run `./gradlew koverMergedReport` to also get XML report
|
// Run `./gradlew koverMergedReport` to also get XML report
|
||||||
koverMerged {
|
koverMerged {
|
||||||
|
|
@ -164,14 +165,70 @@ koverMerged {
|
||||||
classes {
|
classes {
|
||||||
excludes.addAll(
|
excludes.addAll(
|
||||||
listOf(
|
listOf(
|
||||||
/*
|
// Exclude generated classes.
|
||||||
"*Fragment",
|
"*_ModuleKt",
|
||||||
"*Fragment\$*",
|
"anvil.hint.binding.io.element.*",
|
||||||
"*Activity",
|
"anvil.hint.merge.*",
|
||||||
"*Activity\$*",
|
"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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -42,6 +42,13 @@ dependencies {
|
||||||
implementation(projects.libraries.testtags)
|
implementation(projects.libraries.testtags)
|
||||||
implementation(projects.libraries.uiStrings)
|
implementation(projects.libraries.uiStrings)
|
||||||
ksp(libs.showkase.processor)
|
ksp(libs.showkase.processor)
|
||||||
|
|
||||||
testImplementation(libs.test.junit)
|
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)
|
androidTestImplementation(libs.test.junitext)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -40,6 +40,13 @@ dependencies {
|
||||||
implementation(projects.libraries.elementresources)
|
implementation(projects.libraries.elementresources)
|
||||||
implementation(projects.libraries.uiStrings)
|
implementation(projects.libraries.uiStrings)
|
||||||
ksp(libs.showkase.processor)
|
ksp(libs.showkase.processor)
|
||||||
|
|
||||||
testImplementation(libs.test.junit)
|
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)
|
androidTestImplementation(libs.test.junitext)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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<LogoutPreferenceState>(A_THROWABLE))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
@ -44,7 +44,14 @@ dependencies {
|
||||||
implementation(libs.accompanist.flowlayout)
|
implementation(libs.accompanist.flowlayout)
|
||||||
implementation(libs.androidx.recyclerview)
|
implementation(libs.androidx.recyclerview)
|
||||||
implementation(libs.jsoup)
|
implementation(libs.jsoup)
|
||||||
|
|
||||||
testImplementation(libs.test.junit)
|
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)
|
androidTestImplementation(libs.test.junitext)
|
||||||
ksp(libs.showkase.processor)
|
ksp(libs.showkase.processor)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -59,7 +59,10 @@ class MessageComposerPresenter @Inject constructor(
|
||||||
when (event) {
|
when (event) {
|
||||||
MessageComposerEvents.ToggleFullScreenState -> isFullScreen.value = !isFullScreen.value
|
MessageComposerEvents.ToggleFullScreenState -> isFullScreen.value = !isFullScreen.value
|
||||||
is MessageComposerEvents.UpdateText -> text.value = event.text.toStableCharSequence()
|
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.SendMessage -> appCoroutineScope.sendMessage(event.message, composerMode, text)
|
||||||
is MessageComposerEvents.SetMode -> composerMode.value = event.composerMode
|
is MessageComposerEvents.SetMode -> composerMode.value = event.composerMode
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -352,11 +352,6 @@ internal fun TimelineLoadingMoreIndicator() {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
class MessagesItemGroupPositionToMessagesTimelineItemContentProvider :
|
|
||||||
PairCombinedPreviewParameter<MessagesItemGroupPosition, TimelineItemContent>(
|
|
||||||
TimelineItemGroupPositionProvider() to MessagesTimelineItemContentProvider()
|
|
||||||
)
|
|
||||||
|
|
||||||
@Preview
|
@Preview
|
||||||
@Composable
|
@Composable
|
||||||
fun LoginRootScreenLightPreview(
|
fun LoginRootScreenLightPreview(
|
||||||
|
|
|
||||||
|
|
@ -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())
|
||||||
|
)
|
||||||
|
|
@ -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())
|
||||||
|
)
|
||||||
|
|
@ -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<MessageComposerState>.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)
|
||||||
|
|
@ -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(),
|
||||||
|
)
|
||||||
|
|
@ -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"))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -59,8 +59,8 @@ fun OnBoardingScreen(
|
||||||
onSignUp: () -> Unit = {},
|
onSignUp: () -> Unit = {},
|
||||||
onSignIn: () -> Unit = {},
|
onSignIn: () -> Unit = {},
|
||||||
) {
|
) {
|
||||||
val carrouselState = remember { SplashCarouselStateFactory().create() }
|
val carrouselData = remember { SplashCarouselDataFactory().create() }
|
||||||
val nbOfPages = carrouselState.items.size
|
val nbOfPages = carrouselData.items.size
|
||||||
var key by remember { mutableStateOf(false) }
|
var key by remember { mutableStateOf(false) }
|
||||||
Box(
|
Box(
|
||||||
modifier = modifier
|
modifier = modifier
|
||||||
|
|
@ -92,7 +92,7 @@ fun OnBoardingScreen(
|
||||||
state = pagerState,
|
state = pagerState,
|
||||||
) { page ->
|
) { page ->
|
||||||
// Our page content
|
// Our page content
|
||||||
OnBoardingPage(carrouselState.items[page])
|
OnBoardingPage(carrouselData.items[page])
|
||||||
}
|
}
|
||||||
HorizontalPagerIndicator(
|
HorizontalPagerIndicator(
|
||||||
pagerState = pagerState,
|
pagerState = pagerState,
|
||||||
|
|
@ -118,7 +118,7 @@ fun OnBoardingScreen(
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
fun OnBoardingPage(
|
fun OnBoardingPage(
|
||||||
item: SplashCarouselState.Item,
|
item: SplashCarouselData.Item,
|
||||||
modifier: Modifier = Modifier,
|
modifier: Modifier = Modifier,
|
||||||
) {
|
) {
|
||||||
Box(
|
Box(
|
||||||
|
|
|
||||||
|
|
@ -19,7 +19,7 @@ package io.element.android.features.onboarding
|
||||||
import androidx.annotation.DrawableRes
|
import androidx.annotation.DrawableRes
|
||||||
import androidx.annotation.StringRes
|
import androidx.annotation.StringRes
|
||||||
|
|
||||||
data class SplashCarouselState(
|
data class SplashCarouselData(
|
||||||
val items: List<Item>
|
val items: List<Item>
|
||||||
) {
|
) {
|
||||||
data class Item(
|
data class Item(
|
||||||
|
|
@ -19,8 +19,8 @@ package io.element.android.features.onboarding
|
||||||
import androidx.annotation.DrawableRes
|
import androidx.annotation.DrawableRes
|
||||||
import io.element.android.libraries.ui.strings.R as StringR
|
import io.element.android.libraries.ui.strings.R as StringR
|
||||||
|
|
||||||
class SplashCarouselStateFactory {
|
class SplashCarouselDataFactory {
|
||||||
fun create(): SplashCarouselState {
|
fun create(): SplashCarouselData {
|
||||||
val lightTheme = true
|
val lightTheme = true
|
||||||
|
|
||||||
fun background(@DrawableRes lightDrawable: Int) =
|
fun background(@DrawableRes lightDrawable: Int) =
|
||||||
|
|
@ -29,9 +29,9 @@ class SplashCarouselStateFactory {
|
||||||
fun hero(@DrawableRes lightDrawable: Int, @DrawableRes darkDrawable: Int) =
|
fun hero(@DrawableRes lightDrawable: Int, @DrawableRes darkDrawable: Int) =
|
||||||
if (lightTheme) lightDrawable else darkDrawable
|
if (lightTheme) lightDrawable else darkDrawable
|
||||||
|
|
||||||
return SplashCarouselState(
|
return SplashCarouselData(
|
||||||
listOf(
|
listOf(
|
||||||
SplashCarouselState.Item(
|
SplashCarouselData.Item(
|
||||||
StringR.string.ftue_auth_carousel_secure_title,
|
StringR.string.ftue_auth_carousel_secure_title,
|
||||||
StringR.string.ftue_auth_carousel_secure_body,
|
StringR.string.ftue_auth_carousel_secure_body,
|
||||||
hero(
|
hero(
|
||||||
|
|
@ -40,19 +40,19 @@ class SplashCarouselStateFactory {
|
||||||
),
|
),
|
||||||
background(R.drawable.bg_carousel_page_1)
|
background(R.drawable.bg_carousel_page_1)
|
||||||
),
|
),
|
||||||
SplashCarouselState.Item(
|
SplashCarouselData.Item(
|
||||||
StringR.string.ftue_auth_carousel_control_title,
|
StringR.string.ftue_auth_carousel_control_title,
|
||||||
StringR.string.ftue_auth_carousel_control_body,
|
StringR.string.ftue_auth_carousel_control_body,
|
||||||
hero(R.drawable.ic_splash_control, R.drawable.ic_splash_control_dark),
|
hero(R.drawable.ic_splash_control, R.drawable.ic_splash_control_dark),
|
||||||
background(R.drawable.bg_carousel_page_2)
|
background(R.drawable.bg_carousel_page_2)
|
||||||
),
|
),
|
||||||
SplashCarouselState.Item(
|
SplashCarouselData.Item(
|
||||||
StringR.string.ftue_auth_carousel_encrypted_title,
|
StringR.string.ftue_auth_carousel_encrypted_title,
|
||||||
StringR.string.ftue_auth_carousel_encrypted_body,
|
StringR.string.ftue_auth_carousel_encrypted_body,
|
||||||
hero(R.drawable.ic_splash_secure, R.drawable.ic_splash_secure_dark),
|
hero(R.drawable.ic_splash_secure, R.drawable.ic_splash_secure_dark),
|
||||||
background(R.drawable.bg_carousel_page_3)
|
background(R.drawable.bg_carousel_page_3)
|
||||||
),
|
),
|
||||||
SplashCarouselState.Item(
|
SplashCarouselData.Item(
|
||||||
collaborationTitle(),
|
collaborationTitle(),
|
||||||
StringR.string.ftue_auth_carousel_workplace_body,
|
StringR.string.ftue_auth_carousel_workplace_body,
|
||||||
hero(
|
hero(
|
||||||
|
|
@ -44,7 +44,14 @@ dependencies {
|
||||||
implementation(projects.libraries.uiStrings)
|
implementation(projects.libraries.uiStrings)
|
||||||
implementation(libs.datetime)
|
implementation(libs.datetime)
|
||||||
implementation(libs.accompanist.placeholder)
|
implementation(libs.accompanist.placeholder)
|
||||||
|
|
||||||
testImplementation(libs.test.junit)
|
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)
|
androidTestImplementation(libs.test.junitext)
|
||||||
ksp(libs.showkase.processor)
|
ksp(libs.showkase.processor)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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()
|
||||||
|
}
|
||||||
|
|
@ -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<Boolean> = isEnabledFlow
|
||||||
|
|
||||||
|
override suspend fun setIsEnabled(isEnabled: Boolean) {
|
||||||
|
isEnabledFlow.value = isEnabled
|
||||||
|
}
|
||||||
|
|
||||||
|
private val sensitivityFlow = MutableStateFlow(sensitivity)
|
||||||
|
override fun sensitivity(): Flow<Float> = sensitivityFlow
|
||||||
|
|
||||||
|
override suspend fun setSensitivity(sensitivity: Float) {
|
||||||
|
sensitivityFlow.value = sensitivity
|
||||||
|
}
|
||||||
|
|
||||||
|
override suspend fun reset() = Unit
|
||||||
|
}
|
||||||
|
|
@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -40,11 +40,19 @@ dependencies {
|
||||||
implementation(projects.libraries.designsystem)
|
implementation(projects.libraries.designsystem)
|
||||||
implementation(projects.libraries.elementresources)
|
implementation(projects.libraries.elementresources)
|
||||||
implementation(projects.libraries.uiStrings)
|
implementation(projects.libraries.uiStrings)
|
||||||
implementation(libs.squareup.seismic)
|
api(libs.squareup.seismic)
|
||||||
implementation(libs.androidx.datastore.preferences)
|
implementation(libs.androidx.datastore.preferences)
|
||||||
implementation(libs.coil)
|
implementation(libs.coil)
|
||||||
implementation(libs.coil.compose)
|
implementation(libs.coil.compose)
|
||||||
ksp(libs.showkase.processor)
|
ksp(libs.showkase.processor)
|
||||||
|
|
||||||
testImplementation(libs.test.junit)
|
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)
|
androidTestImplementation(libs.test.junitext)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -23,10 +23,10 @@ import androidx.compose.runtime.getValue
|
||||||
import androidx.compose.runtime.mutableStateOf
|
import androidx.compose.runtime.mutableStateOf
|
||||||
import androidx.compose.runtime.remember
|
import androidx.compose.runtime.remember
|
||||||
import androidx.compose.runtime.saveable.rememberSaveable
|
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.crash.CrashDataStore
|
||||||
import io.element.android.features.rageshake.logs.VectorFileLogger
|
import io.element.android.features.rageshake.logs.VectorFileLogger
|
||||||
import io.element.android.features.rageshake.reporter.BugReporter
|
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.reporter.ReportType
|
||||||
import io.element.android.features.rageshake.screenshot.ScreenshotHolder
|
import io.element.android.features.rageshake.screenshot.ScreenshotHolder
|
||||||
import io.element.android.libraries.architecture.Async
|
import io.element.android.libraries.architecture.Async
|
||||||
|
|
@ -45,7 +45,7 @@ class BugReportPresenter @Inject constructor(
|
||||||
private class BugReporterUploadListener(
|
private class BugReporterUploadListener(
|
||||||
private val sendingProgress: MutableState<Float>,
|
private val sendingProgress: MutableState<Float>,
|
||||||
private val sendingAction: MutableState<Async<Unit>>
|
private val sendingAction: MutableState<Async<Unit>>
|
||||||
) : BugReporter.IMXBugReportListener {
|
) : BugReporterListener {
|
||||||
|
|
||||||
override fun onUploadCancelled() {
|
override fun onUploadCancelled() {
|
||||||
sendingProgress.value = 0f
|
sendingProgress.value = 0f
|
||||||
|
|
@ -72,7 +72,7 @@ class BugReportPresenter @Inject constructor(
|
||||||
override fun present(): BugReportState {
|
override fun present(): BugReportState {
|
||||||
val screenshotUri = rememberSaveable {
|
val screenshotUri = rememberSaveable {
|
||||||
mutableStateOf(
|
mutableStateOf(
|
||||||
screenshotHolder.getFile()?.toUri()?.toString()
|
screenshotHolder.getFileUri()
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
val crashInfo: String by crashDataStore
|
val crashInfo: String by crashDataStore
|
||||||
|
|
@ -126,7 +126,11 @@ class BugReportPresenter @Inject constructor(
|
||||||
formState.value = operation(formState.value)
|
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(
|
bugReporter.sendBugReport(
|
||||||
coroutineScope = this,
|
coroutineScope = this,
|
||||||
reportType = ReportType.BUG_REPORT,
|
reportType = ReportType.BUG_REPORT,
|
||||||
|
|
@ -145,6 +149,6 @@ class BugReportPresenter @Inject constructor(
|
||||||
private fun CoroutineScope.resetAll() = launch {
|
private fun CoroutineScope.resetAll() = launch {
|
||||||
screenshotHolder.reset()
|
screenshotHolder.reset()
|
||||||
crashDataStore.reset()
|
crashDataStore.reset()
|
||||||
VectorFileLogger.getFromTimber().reset()
|
VectorFileLogger.getFromTimber()?.reset()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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");
|
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
* you may not use this file except in compliance with 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
|
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.Flow
|
||||||
import kotlinx.coroutines.flow.map
|
|
||||||
import kotlinx.coroutines.runBlocking
|
|
||||||
import javax.inject.Inject
|
|
||||||
|
|
||||||
private val Context.dataStore: DataStore<Preferences> by preferencesDataStore(name = "elementx_crash")
|
interface CrashDataStore {
|
||||||
|
fun setCrashData(crashData: String)
|
||||||
|
|
||||||
private val appHasCrashedKey = booleanPreferencesKey("appHasCrashed")
|
suspend fun resetAppHasCrashed()
|
||||||
private val crashDataKey = stringPreferencesKey("crashData")
|
fun appHasCrashed(): Flow<Boolean>
|
||||||
|
fun crashInfo(): Flow<String>
|
||||||
|
|
||||||
class CrashDataStore @Inject constructor(
|
suspend fun reset()
|
||||||
@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<Boolean> {
|
|
||||||
return store.data.map { prefs ->
|
|
||||||
prefs[appHasCrashedKey].orFalse()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fun crashInfo(): Flow<String> {
|
|
||||||
return store.data.map { prefs ->
|
|
||||||
prefs[crashDataKey].orEmpty()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
suspend fun reset() {
|
|
||||||
store.edit { it.clear() }
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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<Preferences> 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<Boolean> {
|
||||||
|
return store.data.map { prefs ->
|
||||||
|
prefs[appHasCrashedKey].orFalse()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun crashInfo(): Flow<String> {
|
||||||
|
return store.data.map { prefs ->
|
||||||
|
prefs[crashDataKey].orEmpty()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override suspend fun reset() {
|
||||||
|
store.edit { it.clear() }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -26,7 +26,7 @@ import java.io.StringWriter
|
||||||
class VectorUncaughtExceptionHandler(
|
class VectorUncaughtExceptionHandler(
|
||||||
context: Context
|
context: Context
|
||||||
) : Thread.UncaughtExceptionHandler {
|
) : Thread.UncaughtExceptionHandler {
|
||||||
private val crashDataStore = CrashDataStore(context)
|
private val crashDataStore = PreferencesCrashDataStore(context)
|
||||||
private var previousHandler: Thread.UncaughtExceptionHandler? = null
|
private var previousHandler: Thread.UncaughtExceptionHandler? = null
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
|
||||||
|
|
@ -95,12 +95,12 @@ class RageshakeDetectionPresenter @Inject constructor(
|
||||||
private fun handleRageShake(start: Boolean, state: RageshakeDetectionState, takeScreenshot: MutableState<Boolean>) {
|
private fun handleRageShake(start: Boolean, state: RageshakeDetectionState, takeScreenshot: MutableState<Boolean>) {
|
||||||
if (start) {
|
if (start) {
|
||||||
rageShake.start(state.preferenceState.sensitivity)
|
rageShake.start(state.preferenceState.sensitivity)
|
||||||
rageShake.interceptor = {
|
rageShake.setInterceptor {
|
||||||
takeScreenshot.value = true
|
takeScreenshot.value = true
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
rageShake.stop()
|
rageShake.stop()
|
||||||
rageShake.interceptor = null
|
rageShake.setInterceptor(null)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -43,8 +43,8 @@ class VectorFileLogger(
|
||||||
) : Timber.Tree() {
|
) : Timber.Tree() {
|
||||||
|
|
||||||
companion object {
|
companion object {
|
||||||
fun getFromTimber(): VectorFileLogger {
|
fun getFromTimber(): VectorFileLogger? {
|
||||||
return Timber.forest().filterIsInstance<VectorFileLogger>().first()
|
return Timber.forest().filterIsInstance<VectorFileLogger>().firstOrNull()
|
||||||
}
|
}
|
||||||
|
|
||||||
private const val SIZE_20MB = 20 * 1024 * 1024
|
private const val SIZE_20MB = 20 * 1024 * 1024
|
||||||
|
|
|
||||||
|
|
@ -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<SensorManager>()
|
||||||
|
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()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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<Preferences> 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<Boolean> {
|
||||||
|
return store.data.map { prefs ->
|
||||||
|
prefs[enabledKey].orTrue()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override suspend fun setIsEnabled(isEnabled: Boolean) {
|
||||||
|
store.edit { prefs ->
|
||||||
|
prefs[enabledKey] = isEnabled
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun sensitivity(): Flow<Float> {
|
||||||
|
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() }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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");
|
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
* you may not use this file except in compliance with 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
|
package io.element.android.features.rageshake.rageshake
|
||||||
|
|
||||||
import android.content.Context
|
interface RageShake {
|
||||||
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<SensorManager>()
|
|
||||||
private var shakeDetector: ShakeDetector? = null
|
|
||||||
|
|
||||||
var interceptor: (() -> Unit)? = null
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Check if the feature is available on this device.
|
* Check if the feature is available on this device.
|
||||||
*/
|
*/
|
||||||
fun isAvailable(): Boolean {
|
fun isAvailable(): Boolean
|
||||||
return sensorManager?.getDefaultSensor(Sensor.TYPE_ACCELEROMETER) != null
|
|
||||||
}
|
|
||||||
|
|
||||||
fun start(sensitivity: Float) {
|
fun start(sensitivity: Float)
|
||||||
sensorManager?.let {
|
|
||||||
shakeDetector = ShakeDetector(this).apply {
|
|
||||||
start(it, SensorManager.SENSOR_DELAY_GAME)
|
|
||||||
}
|
|
||||||
setSensitivity(sensitivity)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fun stop() {
|
fun stop()
|
||||||
shakeDetector?.stop()
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* sensitivity will be {0, O.25, 0.5, 0.75, 1} and converted to
|
* sensitivity will be {0, O.25, 0.5, 0.75, 1} and converted to
|
||||||
* [ShakeDetector.SENSITIVITY_LIGHT (=11), ShakeDetector.SENSITIVITY_HARD (=15)].
|
* [ShakeDetector.SENSITIVITY_LIGHT (=11), ShakeDetector.SENSITIVITY_HARD (=15)].
|
||||||
*/
|
*/
|
||||||
fun setSensitivity(sensitivity: Float) {
|
fun setSensitivity(sensitivity: Float)
|
||||||
shakeDetector?.setSensitivity(
|
|
||||||
ShakeDetector.SENSITIVITY_LIGHT + (sensitivity * 4).toInt()
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun hearShake() {
|
fun setInterceptor(interceptor: (() -> Unit)?)
|
||||||
interceptor?.invoke()
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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");
|
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
* you may not use this file except in compliance with 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
|
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.Flow
|
||||||
import kotlinx.coroutines.flow.map
|
|
||||||
import javax.inject.Inject
|
|
||||||
|
|
||||||
private val Context.dataStore: DataStore<Preferences> by preferencesDataStore(name = "elementx_rageshake")
|
interface RageshakeDataStore {
|
||||||
|
fun isEnabled(): Flow<Boolean>
|
||||||
|
|
||||||
private val enabledKey = booleanPreferencesKey("enabled")
|
suspend fun setIsEnabled(isEnabled: Boolean)
|
||||||
private val sensitivityKey = floatPreferencesKey("sensitivity")
|
|
||||||
|
|
||||||
class RageshakeDataStore @Inject constructor(
|
fun sensitivity(): Flow<Float>
|
||||||
@ApplicationContext context: Context
|
|
||||||
) {
|
|
||||||
private val store = context.dataStore
|
|
||||||
|
|
||||||
fun isEnabled(): Flow<Boolean> {
|
suspend fun setSensitivity(sensitivity: Float)
|
||||||
return store.data.map { prefs ->
|
|
||||||
prefs[enabledKey].orTrue()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
suspend fun setIsEnabled(isEnabled: Boolean) {
|
suspend fun reset()
|
||||||
store.edit { prefs ->
|
|
||||||
prefs[enabledKey] = isEnabled
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fun sensitivity(): Flow<Float> {
|
|
||||||
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() }
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
|
||||||
507
features/rageshake/src/main/kotlin/io/element/android/features/rageshake/reporter/BugReporter.kt
Executable file → Normal file
507
features/rageshake/src/main/kotlin/io/element/android/features/rageshake/reporter/BugReporter.kt
Executable file → Normal file
|
|
@ -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");
|
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
* you may not use this file except in compliance with 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
|
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.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<JsonDict>(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.
|
* Send a bug report.
|
||||||
*
|
*
|
||||||
|
|
@ -162,388 +45,6 @@ class BugReporter @Inject constructor(
|
||||||
serverVersion: String,
|
serverVersion: String,
|
||||||
canContact: Boolean = false,
|
canContact: Boolean = false,
|
||||||
customFields: Map<String, String>? = null,
|
customFields: Map<String, String>? = null,
|
||||||
listener: IMXBugReportListener?
|
listener: BugReporterListener?
|
||||||
) {
|
)
|
||||||
// enumerate files to delete
|
|
||||||
val mBugReportFiles: MutableList<File> = 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<File>()
|
|
||||||
|
|
||||||
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")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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?)
|
||||||
|
}
|
||||||
|
|
@ -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<JsonDict>(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<String, String>?,
|
||||||
|
listener: BugReporterListener?
|
||||||
|
) {
|
||||||
|
// enumerate files to delete
|
||||||
|
val mBugReportFiles: MutableList<File> = 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<File>()
|
||||||
|
|
||||||
|
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")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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");
|
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
* you may not use this file except in compliance with 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
|
package io.element.android.features.rageshake.screenshot
|
||||||
|
|
||||||
import android.content.Context
|
|
||||||
import android.graphics.Bitmap
|
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)
|
interface ScreenshotHolder {
|
||||||
class ScreenshotHolder @Inject constructor(
|
fun writeBitmap(data: Bitmap)
|
||||||
@ApplicationContext private val context: Context,
|
fun getFileUri(): String?
|
||||||
) {
|
fun reset()
|
||||||
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()
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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<String, String>?,
|
||||||
|
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
|
||||||
|
}
|
||||||
|
|
@ -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
|
||||||
|
}
|
||||||
|
|
@ -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()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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<Boolean> = appHasCrashedFlow
|
||||||
|
|
||||||
|
override fun crashInfo(): Flow<String> = crashDataFlow
|
||||||
|
|
||||||
|
override suspend fun reset() {
|
||||||
|
appHasCrashedFlow.value = false
|
||||||
|
crashDataFlow.value = ""
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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()
|
||||||
|
|
||||||
|
|
@ -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()
|
||||||
|
}
|
||||||
|
|
@ -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<Boolean> = isEnabledFlow
|
||||||
|
|
||||||
|
override suspend fun setIsEnabled(isEnabled: Boolean) {
|
||||||
|
isEnabledFlow.value = isEnabled
|
||||||
|
}
|
||||||
|
|
||||||
|
private val sensitivityFlow = MutableStateFlow(sensitivity)
|
||||||
|
override fun sensitivity(): Flow<Float> = sensitivityFlow
|
||||||
|
|
||||||
|
override suspend fun setSensitivity(sensitivity: Float) {
|
||||||
|
sensitivityFlow.value = sensitivity
|
||||||
|
}
|
||||||
|
|
||||||
|
override suspend fun reset() = Unit
|
||||||
|
}
|
||||||
|
|
@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
@ -41,7 +41,7 @@ dependencies {
|
||||||
implementation(projects.libraries.designsystem)
|
implementation(projects.libraries.designsystem)
|
||||||
implementation(projects.libraries.elementresources)
|
implementation(projects.libraries.elementresources)
|
||||||
implementation(projects.libraries.uiStrings)
|
implementation(projects.libraries.uiStrings)
|
||||||
implementation(libs.datetime)
|
implementation(projects.libraries.dateformatter)
|
||||||
implementation(libs.accompanist.placeholder)
|
implementation(libs.accompanist.placeholder)
|
||||||
|
|
||||||
testImplementation(libs.test.junit)
|
testImplementation(libs.test.junit)
|
||||||
|
|
@ -51,6 +51,8 @@ dependencies {
|
||||||
testImplementation(libs.test.turbine)
|
testImplementation(libs.test.turbine)
|
||||||
testImplementation(projects.libraries.matrixtest)
|
testImplementation(projects.libraries.matrixtest)
|
||||||
|
|
||||||
|
testImplementation(testFixtures(projects.libraries.matrix))
|
||||||
|
|
||||||
androidTestImplementation(libs.test.junitext)
|
androidTestImplementation(libs.test.junitext)
|
||||||
|
|
||||||
ksp(libs.showkase.processor)
|
ksp(libs.showkase.processor)
|
||||||
|
|
|
||||||
|
|
@ -31,6 +31,7 @@ import io.element.android.features.roomlist.model.RoomListRoomSummaryPlaceholder
|
||||||
import io.element.android.features.roomlist.model.RoomListState
|
import io.element.android.features.roomlist.model.RoomListState
|
||||||
import io.element.android.libraries.architecture.Presenter
|
import io.element.android.libraries.architecture.Presenter
|
||||||
import io.element.android.libraries.core.coroutine.parallelMap
|
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.AvatarData
|
||||||
import io.element.android.libraries.designsystem.components.avatar.AvatarSize
|
import io.element.android.libraries.designsystem.components.avatar.AvatarSize
|
||||||
import io.element.android.libraries.matrix.MatrixClient
|
import io.element.android.libraries.matrix.MatrixClient
|
||||||
|
|
@ -57,7 +58,6 @@ class RoomListPresenter @Inject constructor(
|
||||||
mutableStateOf(null)
|
mutableStateOf(null)
|
||||||
}
|
}
|
||||||
var filter by rememberSaveable { mutableStateOf("") }
|
var filter by rememberSaveable { mutableStateOf("") }
|
||||||
val isLoginOut = rememberSaveable { mutableStateOf(false) }
|
|
||||||
val roomSummaries by client
|
val roomSummaries by client
|
||||||
.roomSummaryDataSource()
|
.roomSummaryDataSource()
|
||||||
.roomSummaries()
|
.roomSummaries()
|
||||||
|
|
@ -86,7 +86,6 @@ class RoomListPresenter @Inject constructor(
|
||||||
matrixUser = matrixUser.value,
|
matrixUser = matrixUser.value,
|
||||||
roomList = filteredRoomSummaries.value,
|
roomList = filteredRoomSummaries.value,
|
||||||
filter = filter,
|
filter = filter,
|
||||||
isLoginOut = isLoginOut.value,
|
|
||||||
eventSink = ::handleEvents
|
eventSink = ::handleEvents
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -33,8 +33,8 @@ object RoomListRoomSummaryPlaceholders {
|
||||||
|
|
||||||
fun createFakeList(size: Int): List<RoomListRoomSummary> {
|
fun createFakeList(size: Int): List<RoomListRoomSummary> {
|
||||||
return mutableListOf<RoomListRoomSummary>().apply {
|
return mutableListOf<RoomListRoomSummary>().apply {
|
||||||
for (i in 0..size) {
|
repeat(size) {
|
||||||
add(create("\$fakeRoom$i"))
|
add(create("\$fakeRoom$it"))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -25,6 +25,5 @@ data class RoomListState(
|
||||||
val matrixUser: MatrixUser?,
|
val matrixUser: MatrixUser?,
|
||||||
val roomList: ImmutableList<RoomListRoomSummary>,
|
val roomList: ImmutableList<RoomListRoomSummary>,
|
||||||
val filter: String,
|
val filter: String,
|
||||||
val isLoginOut: Boolean,
|
|
||||||
val eventSink: (RoomListEvents) -> Unit
|
val eventSink: (RoomListEvents) -> Unit
|
||||||
)
|
)
|
||||||
|
|
|
||||||
|
|
@ -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");
|
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
* you may not use this file except in compliance with the License.
|
* you may not use this file except in compliance with the License.
|
||||||
|
|
@ -14,19 +14,17 @@
|
||||||
* limitations under the License.
|
* limitations under the License.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
package io.element.android.features.login
|
package io.element.android.features.roomlist
|
||||||
|
|
||||||
import org.junit.Assert.assertEquals
|
import io.element.android.libraries.dateformatter.LastMessageFormatter
|
||||||
import org.junit.Test
|
|
||||||
|
|
||||||
/**
|
class FakeLastMessageFormatter : LastMessageFormatter {
|
||||||
* Example local unit test, which will execute on the development machine (host).
|
private var format = ""
|
||||||
*
|
fun givenFormat(format: String) {
|
||||||
* See [testing documentation](http://d.android.com/tools/testing).
|
this.format = format
|
||||||
*/
|
}
|
||||||
class ExampleUnitTest {
|
|
||||||
@Test
|
override fun format(timestamp: Long?): String {
|
||||||
fun addition_isCorrect() {
|
return format
|
||||||
assertEquals(4, 2 + 2)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -14,14 +14,30 @@
|
||||||
* limitations under the License.
|
* limitations under the License.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
|
@file:OptIn(ExperimentalCoroutinesApi::class)
|
||||||
|
|
||||||
package io.element.android.features.roomlist
|
package io.element.android.features.roomlist
|
||||||
|
|
||||||
import app.cash.molecule.RecompositionClock
|
import app.cash.molecule.RecompositionClock
|
||||||
import app.cash.molecule.moleculeFlow
|
import app.cash.molecule.moleculeFlow
|
||||||
import app.cash.turbine.test
|
import app.cash.turbine.test
|
||||||
import com.google.common.truth.Truth.assertThat
|
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.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.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 kotlinx.coroutines.test.runTest
|
||||||
import org.junit.Test
|
import org.junit.Test
|
||||||
|
|
||||||
|
|
@ -29,11 +45,11 @@ class RoomListPresenterTests {
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun `present - should start with no user and then load user with success`() = runTest {
|
fun `present - should start with no user and then load user with success`() = runTest {
|
||||||
|
|
||||||
val presenter = RoomListPresenter(
|
val presenter = RoomListPresenter(
|
||||||
FakeMatrixClient(
|
FakeMatrixClient(
|
||||||
SessionId("sessionId")
|
SessionId("sessionId")
|
||||||
), LastMessageFormatter()
|
),
|
||||||
|
createDateFormatter()
|
||||||
)
|
)
|
||||||
moleculeFlow(RecompositionClock.Immediate) {
|
moleculeFlow(RecompositionClock.Immediate) {
|
||||||
presenter.present()
|
presenter.present()
|
||||||
|
|
@ -41,7 +57,166 @@ class RoomListPresenterTests {
|
||||||
val initialState = awaitItem()
|
val initialState = awaitItem()
|
||||||
assertThat(initialState.matrixUser).isNull()
|
assertThat(initialState.matrixUser).isNull()
|
||||||
val withUserState = awaitItem()
|
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,
|
||||||
|
)
|
||||||
|
|
|
||||||
|
|
@ -14,27 +14,39 @@
|
||||||
* limitations under the License.
|
* limitations under the License.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
|
@file:OptIn(ExperimentalCoroutinesApi::class)
|
||||||
|
|
||||||
package io.element.android.features.template
|
package io.element.android.features.template
|
||||||
|
|
||||||
import app.cash.molecule.RecompositionClock
|
import app.cash.molecule.RecompositionClock
|
||||||
import app.cash.molecule.moleculeFlow
|
import app.cash.molecule.moleculeFlow
|
||||||
import app.cash.turbine.test
|
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 kotlinx.coroutines.test.runTest
|
||||||
import org.junit.Test
|
import org.junit.Test
|
||||||
|
|
||||||
class TemplatePresenterTests {
|
class TemplatePresenterTests {
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun `present - `() = runTest {
|
fun `present - initial state`() = runTest {
|
||||||
|
|
||||||
val presenter = TemplatePresenter()
|
val presenter = TemplatePresenter()
|
||||||
moleculeFlow(RecompositionClock.Immediate) {
|
moleculeFlow(RecompositionClock.Immediate) {
|
||||||
presenter.present()
|
presenter.present()
|
||||||
}.test {
|
}.test {
|
||||||
val initialState = awaitItem()
|
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)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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
|
# 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
|
# https://googlesamples.github.io/android-custom-lint-rules/usage/newer-lint.md.html
|
||||||
android.experimental.lint.version=8.0.0-alpha10
|
android.experimental.lint.version=8.0.0-alpha10
|
||||||
|
|
||||||
|
# Enable test fixture for all modules by default
|
||||||
|
android.experimental.enableTestFixtures=true
|
||||||
|
|
|
||||||
|
|
@ -97,7 +97,7 @@ test_junit = "junit:junit:4.13.2"
|
||||||
test_runner = "androidx.test:runner:1.4.0"
|
test_runner = "androidx.test:runner:1.4.0"
|
||||||
test_uiautomator = "androidx.test.uiautomator:uiautomator:2.2.0"
|
test_uiautomator = "androidx.test.uiautomator:uiautomator:2.2.0"
|
||||||
test_junitext = "androidx.test.ext:junit:1.1.3"
|
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_barista = "com.adevinta.android:barista:4.2.0"
|
||||||
test_hamcrest = "org.hamcrest:hamcrest:2.2"
|
test_hamcrest = "org.hamcrest:hamcrest:2.2"
|
||||||
test_orchestrator = "androidx.test:orchestrator:1.4.1"
|
test_orchestrator = "androidx.test:orchestrator:1.4.1"
|
||||||
|
|
|
||||||
|
|
@ -39,7 +39,8 @@ sealed interface Async<out T> {
|
||||||
suspend fun <T> (suspend () -> T).execute(state: MutableState<Async<T>>) {
|
suspend fun <T> (suspend () -> T).execute(state: MutableState<Async<T>>) {
|
||||||
try {
|
try {
|
||||||
state.value = Async.Loading()
|
state.value = Async.Loading()
|
||||||
state.value = Async.Success(this())
|
val result = this()
|
||||||
|
state.value = Async.Success(result)
|
||||||
} catch (error: Throwable) {
|
} catch (error: Throwable) {
|
||||||
state.value = Async.Failure(error)
|
state.value = Async.Failure(error)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -24,6 +24,8 @@ class StableCharSequence(val charSequence: CharSequence) {
|
||||||
|
|
||||||
override fun hashCode() = hash
|
override fun hashCode() = hash
|
||||||
override fun equals(other: Any?) = other is StableCharSequence && other.hash == hash
|
override fun equals(other: Any?) = other is StableCharSequence && other.hash == hash
|
||||||
|
|
||||||
|
override fun toString(): String = "StableCharSequence(\"$charSequence\")"
|
||||||
}
|
}
|
||||||
|
|
||||||
fun CharSequence.toStableCharSequence() = StableCharSequence(this)
|
fun CharSequence.toStableCharSequence() = StableCharSequence(this)
|
||||||
|
|
|
||||||
1
libraries/dateformatter/.gitignore
vendored
Normal file
1
libraries/dateformatter/.gitignore
vendored
Normal file
|
|
@ -0,0 +1 @@
|
||||||
|
/build
|
||||||
43
libraries/dateformatter/build.gradle.kts
Normal file
43
libraries/dateformatter/build.gradle.kts
Normal file
|
|
@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
0
libraries/dateformatter/consumer-rules.pro
Normal file
0
libraries/dateformatter/consumer-rules.pro
Normal file
21
libraries/dateformatter/proguard-rules.pro
vendored
Normal file
21
libraries/dateformatter/proguard-rules.pro
vendored
Normal file
|
|
@ -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
|
||||||
16
libraries/dateformatter/src/main/AndroidManifest.xml
Normal file
16
libraries/dateformatter/src/main/AndroidManifest.xml
Normal file
|
|
@ -0,0 +1,16 @@
|
||||||
|
<?xml version="1.0" encoding="utf-8"?><!--
|
||||||
|
~ 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.
|
||||||
|
-->
|
||||||
|
<manifest />
|
||||||
|
|
@ -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");
|
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
* you may not use this file except in compliance with the License.
|
* you may not use this file except in compliance with the License.
|
||||||
|
|
@ -14,19 +14,8 @@
|
||||||
* limitations under the License.
|
* limitations under the License.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
package io.element.android.features.preferences
|
package io.element.android.libraries.dateformatter
|
||||||
|
|
||||||
import org.junit.Assert.assertEquals
|
interface LastMessageFormatter {
|
||||||
import org.junit.Test
|
fun format(timestamp: Long?): String
|
||||||
|
|
||||||
/**
|
|
||||||
* 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)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
@ -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()
|
||||||
|
}
|
||||||
|
|
@ -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");
|
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
* you may not use this file except in compliance with the License.
|
* you may not use this file except in compliance with the License.
|
||||||
|
|
@ -14,10 +14,13 @@
|
||||||
* limitations under the License.
|
* 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.DateFormat
|
||||||
import android.text.format.DateUtils
|
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.Clock
|
||||||
import kotlinx.datetime.Instant
|
import kotlinx.datetime.Instant
|
||||||
import kotlinx.datetime.LocalDateTime
|
import kotlinx.datetime.LocalDateTime
|
||||||
|
|
@ -32,32 +35,33 @@ import java.util.Locale
|
||||||
import javax.inject.Inject
|
import javax.inject.Inject
|
||||||
import kotlin.math.absoluteValue
|
import kotlin.math.absoluteValue
|
||||||
|
|
||||||
class LastMessageFormatter @Inject constructor() {
|
@ContributesBinding(AppScope::class)
|
||||||
|
class DefaultLastMessageFormatter @Inject constructor(
|
||||||
private val clock: Clock = Clock.System
|
private val clock: Clock,
|
||||||
private val locale: Locale = Locale.getDefault()
|
private val locale: Locale,
|
||||||
|
private val timezone: TimeZone,
|
||||||
|
) : LastMessageFormatter {
|
||||||
private val onlyTimeFormatter: DateTimeFormatter by lazy {
|
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)
|
DateTimeFormatter.ofPattern(pattern)
|
||||||
}
|
}
|
||||||
|
|
||||||
private val dateWithMonthFormatter: DateTimeFormatter by lazy {
|
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)
|
DateTimeFormatter.ofPattern(pattern)
|
||||||
}
|
}
|
||||||
|
|
||||||
private val dateWithYearFormatter: DateTimeFormatter by lazy {
|
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)
|
DateTimeFormatter.ofPattern(pattern)
|
||||||
}
|
}
|
||||||
|
|
||||||
fun format(timestamp: Long?): String {
|
override fun format(timestamp: Long?): String {
|
||||||
if (timestamp == null) return ""
|
if (timestamp == null) return ""
|
||||||
val now: Instant = clock.now()
|
val now: Instant = clock.now()
|
||||||
val tsInstant = Instant.fromEpochMilliseconds(timestamp)
|
val tsInstant = Instant.fromEpochMilliseconds(timestamp)
|
||||||
val nowDateTime = now.toLocalDateTime(TimeZone.currentSystemDefault())
|
val nowDateTime = now.toLocalDateTime(timezone)
|
||||||
val tsDateTime = tsInstant.toLocalDateTime(TimeZone.currentSystemDefault())
|
val tsDateTime = tsInstant.toLocalDateTime(timezone)
|
||||||
val isSameDay = nowDateTime.date == tsDateTime.date
|
val isSameDay = nowDateTime.date == tsDateTime.date
|
||||||
return when {
|
return when {
|
||||||
isSameDay -> {
|
isSameDay -> {
|
||||||
|
|
@ -77,7 +81,7 @@ class LastMessageFormatter @Inject constructor() {
|
||||||
return if (period.years.absoluteValue >= 1) {
|
return if (period.years.absoluteValue >= 1) {
|
||||||
formatDateWithYear(date)
|
formatDateWithYear(date)
|
||||||
} else if (period.days.absoluteValue < 2 && period.months.absoluteValue < 1) {
|
} else if (period.days.absoluteValue < 2 && period.months.absoluteValue < 1) {
|
||||||
getRelativeDay(date.toInstant(TimeZone.currentSystemDefault()).toEpochMilliseconds())
|
getRelativeDay(date.toInstant(timezone).toEpochMilliseconds())
|
||||||
} else {
|
} else {
|
||||||
formatDateWithMonth(date)
|
formatDateWithMonth(date)
|
||||||
}
|
}
|
||||||
|
|
@ -97,6 +101,6 @@ class LastMessageFormatter @Inject constructor() {
|
||||||
clock.now().toEpochMilliseconds(),
|
clock.now().toEpochMilliseconds(),
|
||||||
DateUtils.DAY_IN_MILLIS,
|
DateUtils.DAY_IN_MILLIS,
|
||||||
DateUtils.FORMAT_SHOW_WEEKDAY
|
DateUtils.FORMAT_SHOW_WEEKDAY
|
||||||
).toString()
|
)?.toString() ?: ""
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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");
|
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
* you may not use this file except in compliance with the License.
|
* you may not use this file except in compliance with the License.
|
||||||
|
|
@ -14,19 +14,17 @@
|
||||||
* limitations under the License.
|
* limitations under the License.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
package io.element.android.features.messages
|
package io.element.android.libraries.dateformatter.impl
|
||||||
|
|
||||||
import org.junit.Assert.assertEquals
|
import kotlinx.datetime.Clock
|
||||||
import org.junit.Test
|
import kotlinx.datetime.Instant
|
||||||
|
|
||||||
/**
|
class FakeClock : Clock {
|
||||||
* Example local unit test, which will execute on the development machine (host).
|
private var instant: Instant = Instant.fromEpochMilliseconds(0)
|
||||||
*
|
|
||||||
* See [testing documentation](http://d.android.com/tools/testing).
|
fun givenInstant(instant: Instant) {
|
||||||
*/
|
this.instant = instant
|
||||||
class ExampleUnitTest {
|
|
||||||
@Test
|
|
||||||
fun addition_isCorrect() {
|
|
||||||
assertEquals(4, 2 + 2)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
override fun now(): Instant = instant
|
||||||
}
|
}
|
||||||
|
|
@ -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.matrix.room.RoomSummaryDataSource
|
||||||
import io.element.android.libraries.matrixtest.media.FakeMediaResolver
|
import io.element.android.libraries.matrixtest.media.FakeMediaResolver
|
||||||
import io.element.android.libraries.matrixtest.room.FakeMatrixRoom
|
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
|
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<String> = Result.success(A_USER_NAME),
|
||||||
|
private val userAvatarURLString: Result<String> = Result.success(AN_AVATAR_URL),
|
||||||
|
val roomSummaryDataSource: RoomSummaryDataSource = FakeRoomSummaryDataSource()
|
||||||
|
) : MatrixClient {
|
||||||
|
|
||||||
|
private var logoutFailure: Throwable? = null
|
||||||
|
|
||||||
override fun getRoom(roomId: RoomId): MatrixRoom? {
|
override fun getRoom(roomId: RoomId): MatrixRoom? {
|
||||||
return FakeMatrixRoom(roomId)
|
return FakeMatrixRoom(roomId)
|
||||||
|
|
@ -39,23 +47,30 @@ class FakeMatrixClient(override val sessionId: SessionId) : MatrixClient {
|
||||||
override fun stopSync() = Unit
|
override fun stopSync() = Unit
|
||||||
|
|
||||||
override fun roomSummaryDataSource(): RoomSummaryDataSource {
|
override fun roomSummaryDataSource(): RoomSummaryDataSource {
|
||||||
return InMemoryRoomSummaryDataSource()
|
return roomSummaryDataSource
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun mediaResolver(): MediaResolver {
|
override fun mediaResolver(): MediaResolver {
|
||||||
return FakeMediaResolver()
|
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<String> {
|
override suspend fun loadUserDisplayName(): Result<String> {
|
||||||
return Result.success("")
|
return userDisplayName
|
||||||
}
|
}
|
||||||
|
|
||||||
override suspend fun loadUserAvatarURLString(): Result<String> {
|
override suspend fun loadUserAvatarURLString(): Result<String> {
|
||||||
return Result.success("")
|
return userAvatarURLString
|
||||||
}
|
}
|
||||||
|
|
||||||
override suspend fun loadMediaContentForSource(source: MediaSource): Result<ByteArray> {
|
override suspend fun loadMediaContentForSource(source: MediaSource): Result<ByteArray> {
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
|
||||||
|
|
@ -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<Boolean> {
|
||||||
|
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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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.core.RoomId
|
||||||
import io.element.android.libraries.matrix.room.MatrixRoom
|
import io.element.android.libraries.matrix.room.MatrixRoom
|
||||||
import io.element.android.libraries.matrix.timeline.MatrixTimeline
|
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 io.element.android.libraries.matrixtest.timeline.FakeMatrixTimeline
|
||||||
|
import kotlinx.coroutines.delay
|
||||||
import kotlinx.coroutines.flow.Flow
|
import kotlinx.coroutines.flow.Flow
|
||||||
import kotlinx.coroutines.flow.emptyFlow
|
import kotlinx.coroutines.flow.emptyFlow
|
||||||
|
|
||||||
class FakeMatrixRoom(
|
class FakeMatrixRoom(
|
||||||
override val roomId: RoomId,
|
override val roomId: RoomId = A_ROOM_ID,
|
||||||
override val name: String? = null,
|
override val name: String? = null,
|
||||||
override val bestName: String = "",
|
override val bestName: String = "",
|
||||||
override val displayName: String = "",
|
override val displayName: String = "",
|
||||||
override val topic: String? = null,
|
override val topic: String? = null,
|
||||||
override val avatarUrl: String? = null
|
override val avatarUrl: String? = null,
|
||||||
|
private val matrixTimeline: MatrixTimeline = FakeMatrixTimeline(),
|
||||||
) : MatrixRoom {
|
) : MatrixRoom {
|
||||||
|
|
||||||
override fun syncUpdateFlow(): Flow<Long> {
|
override fun syncUpdateFlow(): Flow<Long> {
|
||||||
|
|
@ -38,7 +41,7 @@ class FakeMatrixRoom(
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun timeline(): MatrixTimeline {
|
override fun timeline(): MatrixTimeline {
|
||||||
return FakeMatrixTimeline()
|
return matrixTimeline
|
||||||
}
|
}
|
||||||
|
|
||||||
override suspend fun userDisplayName(userId: String): Result<String?> {
|
override suspend fun userDisplayName(userId: String): Result<String?> {
|
||||||
|
|
@ -50,18 +53,34 @@ class FakeMatrixRoom(
|
||||||
}
|
}
|
||||||
|
|
||||||
override suspend fun sendMessage(message: String): Result<Unit> {
|
override suspend fun sendMessage(message: String): Result<Unit> {
|
||||||
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<Unit> {
|
override suspend fun editMessage(originalEventId: EventId, message: String): Result<Unit> {
|
||||||
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<Unit> {
|
override suspend fun replyMessage(eventId: EventId, message: String): Result<Unit> {
|
||||||
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<Unit> {
|
override suspend fun redactEvent(eventId: EventId, reason: String?): Result<Unit> {
|
||||||
TODO("Not yet implemented")
|
redactEventEventIdParam = eventId
|
||||||
|
delay(100)
|
||||||
|
return Result.success(Unit)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -21,11 +21,22 @@ import io.element.android.libraries.matrix.room.RoomSummaryDataSource
|
||||||
import kotlinx.coroutines.flow.MutableStateFlow
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
import kotlinx.coroutines.flow.StateFlow
|
import kotlinx.coroutines.flow.StateFlow
|
||||||
|
|
||||||
class InMemoryRoomSummaryDataSource : RoomSummaryDataSource {
|
class FakeRoomSummaryDataSource : RoomSummaryDataSource {
|
||||||
|
|
||||||
override fun roomSummaries(): StateFlow<List<RoomSummary>> {
|
private val roomSummariesFlow = MutableStateFlow<List<RoomSummary>>(emptyList())
|
||||||
return MutableStateFlow(emptyList())
|
|
||||||
|
suspend fun postRoomSummary(roomSummaries: List<RoomSummary>) {
|
||||||
|
roomSummariesFlow.emit(roomSummaries)
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun setSlidingSyncRange(range: IntRange) = Unit
|
override fun roomSummaries(): StateFlow<List<RoomSummary>> {
|
||||||
|
return roomSummariesFlow
|
||||||
|
}
|
||||||
|
|
||||||
|
var latestSlidingSyncRange: IntRange? = null
|
||||||
|
private set
|
||||||
|
|
||||||
|
override fun setSlidingSyncRange(range: IntRange) {
|
||||||
|
latestSlidingSyncRange = range
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -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,
|
||||||
|
)
|
||||||
|
|
@ -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.core.EventId
|
||||||
import io.element.android.libraries.matrix.timeline.MatrixTimeline
|
import io.element.android.libraries.matrix.timeline.MatrixTimeline
|
||||||
import io.element.android.libraries.matrix.timeline.MatrixTimelineItem
|
import io.element.android.libraries.matrix.timeline.MatrixTimelineItem
|
||||||
|
import kotlinx.coroutines.delay
|
||||||
import kotlinx.coroutines.flow.Flow
|
import kotlinx.coroutines.flow.Flow
|
||||||
import kotlinx.coroutines.flow.emptyFlow
|
import kotlinx.coroutines.flow.emptyFlow
|
||||||
import org.matrix.rustcomponents.sdk.TimelineListener
|
import org.matrix.rustcomponents.sdk.TimelineListener
|
||||||
|
|
||||||
class FakeMatrixTimeline : MatrixTimeline {
|
class FakeMatrixTimeline : MatrixTimeline {
|
||||||
|
override var callback: MatrixTimeline.Callback? = null
|
||||||
|
|
||||||
override var callback: MatrixTimeline.Callback?
|
private var hasMoreToLoadValue: Boolean = true
|
||||||
get() = null
|
|
||||||
set(value) {}
|
fun givenHasMoreToLoad(hasMoreToLoad: Boolean) {
|
||||||
|
this.hasMoreToLoadValue = hasMoreToLoad
|
||||||
|
}
|
||||||
|
|
||||||
override val hasMoreToLoad: Boolean
|
override val hasMoreToLoad: Boolean
|
||||||
get() = true
|
get() = hasMoreToLoadValue
|
||||||
|
|
||||||
override fun timelineItems(): Flow<List<MatrixTimelineItem>> {
|
override fun timelineItems(): Flow<List<MatrixTimelineItem>> {
|
||||||
return emptyFlow()
|
return emptyFlow()
|
||||||
}
|
}
|
||||||
|
|
||||||
override suspend fun paginateBackwards(count: Int): Result<Unit> {
|
override suspend fun paginateBackwards(count: Int): Result<Unit> {
|
||||||
|
delay(100)
|
||||||
return Result.success(Unit)
|
return Result.success(Unit)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -40,6 +40,7 @@ fun CommonExtension<*, *, *, *>.androidConfig(project: Project) {
|
||||||
lintConfig = File("${project.rootDir}/tools/lint/lint.xml")
|
lintConfig = File("${project.rootDir}/tools/lint/lint.xml")
|
||||||
checkDependencies = true
|
checkDependencies = true
|
||||||
abortOnError = true
|
abortOnError = true
|
||||||
|
ignoreTestFixturesSources = true
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -64,6 +65,7 @@ fun CommonExtension<*, *, *, *>.composeConfig() {
|
||||||
// Disabled until lint stops inspecting generated ksp files...
|
// Disabled until lint stops inspecting generated ksp files...
|
||||||
// error.add("ComposableLambdaParameterNaming")
|
// error.add("ComposableLambdaParameterNaming")
|
||||||
error.add("ComposableLambdaParameterPosition")
|
error.add("ComposableLambdaParameterPosition")
|
||||||
|
ignoreTestFixturesSources = true
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -54,6 +54,7 @@ fun DependencyHandlerScope.allLibraries() {
|
||||||
implementation(project(":libraries:matrixui"))
|
implementation(project(":libraries:matrixui"))
|
||||||
implementation(project(":libraries:core"))
|
implementation(project(":libraries:core"))
|
||||||
implementation(project(":libraries:architecture"))
|
implementation(project(":libraries:architecture"))
|
||||||
|
implementation(project(":libraries:dateformatter"))
|
||||||
implementation(project(":libraries:di"))
|
implementation(project(":libraries:di"))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -42,6 +42,7 @@ include(":libraries:rustsdk")
|
||||||
include(":libraries:matrix")
|
include(":libraries:matrix")
|
||||||
include(":libraries:matrixui")
|
include(":libraries:matrixui")
|
||||||
include(":libraries:textcomposer")
|
include(":libraries:textcomposer")
|
||||||
|
include(":libraries:dateformatter")
|
||||||
include(":libraries:elementresources")
|
include(":libraries:elementresources")
|
||||||
include(":libraries:ui-strings")
|
include(":libraries:ui-strings")
|
||||||
include(":libraries:testtags")
|
include(":libraries:testtags")
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue