Merge pull request #6040 from element-hq/feature/bma/updateBugReportScreenshot

Ensure screenshot is up to date
This commit is contained in:
Benoit Marty 2026-01-22 09:42:24 +01:00 committed by GitHub
commit 454415225f
19 changed files with 113 additions and 169 deletions

View file

@ -15,9 +15,9 @@ import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.tooling.preview.PreviewParameter import androidx.compose.ui.tooling.preview.PreviewParameter
import io.element.android.features.rageshake.api.crash.CrashDetectionEvents import io.element.android.features.rageshake.api.crash.CrashDetectionEvent
import io.element.android.features.rageshake.api.crash.CrashDetectionView import io.element.android.features.rageshake.api.crash.CrashDetectionView
import io.element.android.features.rageshake.api.detection.RageshakeDetectionEvents import io.element.android.features.rageshake.api.detection.RageshakeDetectionEvent
import io.element.android.features.rageshake.api.detection.RageshakeDetectionView import io.element.android.features.rageshake.api.detection.RageshakeDetectionView
import io.element.android.libraries.designsystem.preview.ElementPreview import io.element.android.libraries.designsystem.preview.ElementPreview
import io.element.android.libraries.designsystem.preview.PreviewsDayNight import io.element.android.libraries.designsystem.preview.PreviewsDayNight
@ -39,8 +39,8 @@ fun RootView(
children() children()
fun onOpenBugReport() { fun onOpenBugReport() {
state.crashDetectionState.eventSink(CrashDetectionEvents.ResetAppHasCrashed) state.crashDetectionState.eventSink(CrashDetectionEvent.ResetAppHasCrashed)
state.rageshakeDetectionState.eventSink(RageshakeDetectionEvents.Dismiss) state.rageshakeDetectionState.eventSink(RageshakeDetectionEvent.Dismiss)
onOpenBugReport.invoke() onOpenBugReport.invoke()
} }

View file

@ -8,7 +8,7 @@
package io.element.android.features.rageshake.api.crash package io.element.android.features.rageshake.api.crash
sealed interface CrashDetectionEvents { sealed interface CrashDetectionEvent {
data object ResetAllCrashData : CrashDetectionEvents data object ResetAllCrashData : CrashDetectionEvent
data object ResetAppHasCrashed : CrashDetectionEvents data object ResetAppHasCrashed : CrashDetectionEvent
} }

View file

@ -11,5 +11,5 @@ package io.element.android.features.rageshake.api.crash
data class CrashDetectionState( data class CrashDetectionState(
val appName: String, val appName: String,
val crashDetected: Boolean, val crashDetected: Boolean,
val eventSink: (CrashDetectionEvents) -> Unit val eventSink: (CrashDetectionEvent) -> Unit
) )

View file

@ -22,7 +22,7 @@ fun CrashDetectionView(
onOpenBugReport: () -> Unit = { }, onOpenBugReport: () -> Unit = { },
) { ) {
fun onPopupDismissed() { fun onPopupDismissed() {
state.eventSink(CrashDetectionEvents.ResetAllCrashData) state.eventSink(CrashDetectionEvent.ResetAllCrashData)
} }
if (state.crashDetected) { if (state.crashDetected) {

View file

@ -10,10 +10,10 @@ package io.element.android.features.rageshake.api.detection
import io.element.android.features.rageshake.api.screenshot.ImageResult import io.element.android.features.rageshake.api.screenshot.ImageResult
sealed interface RageshakeDetectionEvents { sealed interface RageshakeDetectionEvent {
data object Dismiss : RageshakeDetectionEvents data object Dismiss : RageshakeDetectionEvent
data object Disable : RageshakeDetectionEvents data object Disable : RageshakeDetectionEvent
data object StartDetection : RageshakeDetectionEvents data object StartDetection : RageshakeDetectionEvent
data object StopDetection : RageshakeDetectionEvents data object StopDetection : RageshakeDetectionEvent
data class ProcessScreenshot(val imageResult: ImageResult) : RageshakeDetectionEvents data class ProcessScreenshot(val imageResult: ImageResult) : RageshakeDetectionEvent
} }

View file

@ -15,5 +15,5 @@ data class RageshakeDetectionState(
val showDialog: Boolean, val showDialog: Boolean,
val isStarted: Boolean, val isStarted: Boolean,
val preferenceState: RageshakePreferencesState, val preferenceState: RageshakePreferencesState,
val eventSink: (RageshakeDetectionEvents) -> Unit val eventSink: (RageshakeDetectionEvent) -> Unit
) )

View file

@ -35,22 +35,22 @@ fun RageshakeDetectionView(
val context = LocalContext.current val context = LocalContext.current
OnLifecycleEvent { _, event -> OnLifecycleEvent { _, event ->
when (event) { when (event) {
Lifecycle.Event.ON_RESUME -> eventSink(RageshakeDetectionEvents.StartDetection) Lifecycle.Event.ON_RESUME -> eventSink(RageshakeDetectionEvent.StartDetection)
Lifecycle.Event.ON_PAUSE -> eventSink(RageshakeDetectionEvents.StopDetection) Lifecycle.Event.ON_PAUSE -> eventSink(RageshakeDetectionEvent.StopDetection)
else -> Unit else -> Unit
} }
} }
when { when {
state.takeScreenshot -> TakeScreenshot( state.takeScreenshot -> TakeScreenshot(
onScreenshot = { eventSink(RageshakeDetectionEvents.ProcessScreenshot(it)) } onScreenshot = { eventSink(RageshakeDetectionEvent.ProcessScreenshot(it)) }
) )
state.showDialog -> { state.showDialog -> {
LaunchedEffect(Unit) { LaunchedEffect(Unit) {
context.vibrate() context.vibrate()
} }
RageshakeDialogContent( RageshakeDialogContent(
onNoClick = { eventSink(RageshakeDetectionEvents.Dismiss) }, onNoClick = { eventSink(RageshakeDetectionEvent.Dismiss) },
onDisableClick = { eventSink(RageshakeDetectionEvents.Disable) }, onDisableClick = { eventSink(RageshakeDetectionEvent.Disable) },
onYesClick = onOpenBugReport onYesClick = onOpenBugReport
) )
} }

View file

@ -8,7 +8,7 @@
package io.element.android.features.rageshake.api.preferences package io.element.android.features.rageshake.api.preferences
sealed interface RageshakePreferencesEvents { sealed interface RageshakePreferencesEvent {
data class SetSensitivity(val sensitivity: Float) : RageshakePreferencesEvents data class SetSensitivity(val sensitivity: Float) : RageshakePreferencesEvent
data class SetIsEnabled(val isEnabled: Boolean) : RageshakePreferencesEvents data class SetIsEnabled(val isEnabled: Boolean) : RageshakePreferencesEvent
} }

View file

@ -13,5 +13,5 @@ data class RageshakePreferencesState(
val isEnabled: Boolean, val isEnabled: Boolean,
val isSupported: Boolean, val isSupported: Boolean,
val sensitivity: Float, val sensitivity: Float,
val eventSink: (RageshakePreferencesEvents) -> Unit, val eventSink: (RageshakePreferencesEvent) -> Unit,
) )

View file

@ -23,7 +23,7 @@ fun aRageshakePreferencesState(
isEnabled: Boolean = false, isEnabled: Boolean = false,
isSupported: Boolean = true, isSupported: Boolean = true,
sensitivity: Float = 0.3f, sensitivity: Float = 0.3f,
eventSink: (RageshakePreferencesEvents) -> Unit = {} eventSink: (RageshakePreferencesEvent) -> Unit = {}
) = RageshakePreferencesState( ) = RageshakePreferencesState(
isFeatureEnabled = isFeatureEnabled, isFeatureEnabled = isFeatureEnabled,
isEnabled = isEnabled, isEnabled = isEnabled,

View file

@ -29,11 +29,11 @@ fun RageshakePreferencesView(
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
) { ) {
fun onSensitivityChanged(sensitivity: Float) { fun onSensitivityChanged(sensitivity: Float) {
state.eventSink(RageshakePreferencesEvents.SetSensitivity(sensitivity = sensitivity)) state.eventSink(RageshakePreferencesEvent.SetSensitivity(sensitivity = sensitivity))
} }
fun onEnabledChanged(isEnabled: Boolean) { fun onEnabledChanged(isEnabled: Boolean) {
state.eventSink(RageshakePreferencesEvents.SetIsEnabled(isEnabled = isEnabled)) state.eventSink(RageshakePreferencesEvent.SetIsEnabled(isEnabled = isEnabled))
} }
Column(modifier = modifier) { Column(modifier = modifier) {

View file

@ -32,6 +32,7 @@ import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.tooling.preview.PreviewParameter import androidx.compose.ui.tooling.preview.PreviewParameter
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import coil3.compose.AsyncImage import coil3.compose.AsyncImage
import coil3.request.CachePolicy
import coil3.request.ImageRequest import coil3.request.ImageRequest
import io.element.android.features.rageshake.impl.R import io.element.android.features.rageshake.impl.R
import io.element.android.libraries.architecture.AsyncAction import io.element.android.libraries.architecture.AsyncAction
@ -135,6 +136,9 @@ fun BugReportView(
val context = LocalContext.current val context = LocalContext.current
val model = ImageRequest.Builder(context) val model = ImageRequest.Builder(context)
.data(state.screenshotUri) .data(state.screenshotUri)
// Since `screenshotUri` always has the same value, we need to disable memory cache to
// ensure the image is reloaded when the URI content changes
.memoryCachePolicy(CachePolicy.DISABLED)
.build() .build()
AsyncImage( AsyncImage(
modifier = Modifier.fillMaxWidth(fraction = 0.5f), modifier = Modifier.fillMaxWidth(fraction = 0.5f),

View file

@ -18,7 +18,7 @@ import androidx.compose.runtime.rememberCoroutineScope
import dev.zacsweers.metro.AppScope import dev.zacsweers.metro.AppScope
import dev.zacsweers.metro.ContributesBinding import dev.zacsweers.metro.ContributesBinding
import io.element.android.features.rageshake.api.RageshakeFeatureAvailability import io.element.android.features.rageshake.api.RageshakeFeatureAvailability
import io.element.android.features.rageshake.api.crash.CrashDetectionEvents import io.element.android.features.rageshake.api.crash.CrashDetectionEvent
import io.element.android.features.rageshake.api.crash.CrashDetectionPresenter import io.element.android.features.rageshake.api.crash.CrashDetectionPresenter
import io.element.android.features.rageshake.api.crash.CrashDetectionState import io.element.android.features.rageshake.api.crash.CrashDetectionState
import io.element.android.libraries.core.meta.BuildMeta import io.element.android.libraries.core.meta.BuildMeta
@ -48,10 +48,10 @@ class DefaultCrashDetectionPresenter(
} }
}.collectAsState(false) }.collectAsState(false)
fun handleEvent(event: CrashDetectionEvents) { fun handleEvent(event: CrashDetectionEvent) {
when (event) { when (event) {
CrashDetectionEvents.ResetAllCrashData -> localCoroutineScope.resetAll() CrashDetectionEvent.ResetAllCrashData -> localCoroutineScope.resetAll()
CrashDetectionEvents.ResetAppHasCrashed -> localCoroutineScope.resetAppHasCrashed() CrashDetectionEvent.ResetAppHasCrashed -> localCoroutineScope.resetAppHasCrashed()
} }
} }

View file

@ -12,15 +12,14 @@ import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.MutableState import androidx.compose.runtime.MutableState
import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.runtime.saveable.rememberSaveable
import dev.zacsweers.metro.AppScope import dev.zacsweers.metro.AppScope
import dev.zacsweers.metro.ContributesBinding import dev.zacsweers.metro.ContributesBinding
import io.element.android.features.rageshake.api.detection.RageshakeDetectionEvents import io.element.android.features.rageshake.api.detection.RageshakeDetectionEvent
import io.element.android.features.rageshake.api.detection.RageshakeDetectionPresenter import io.element.android.features.rageshake.api.detection.RageshakeDetectionPresenter
import io.element.android.features.rageshake.api.detection.RageshakeDetectionState import io.element.android.features.rageshake.api.detection.RageshakeDetectionState
import io.element.android.features.rageshake.api.preferences.RageshakePreferencesEvents import io.element.android.features.rageshake.api.preferences.RageshakePreferencesEvent
import io.element.android.features.rageshake.api.preferences.RageshakePreferencesPresenter import io.element.android.features.rageshake.api.preferences.RageshakePreferencesPresenter
import io.element.android.features.rageshake.api.screenshot.ImageResult import io.element.android.features.rageshake.api.screenshot.ImageResult
import io.element.android.features.rageshake.impl.rageshake.RageShake import io.element.android.features.rageshake.impl.rageshake.RageShake
@ -49,29 +48,19 @@ class DefaultRageshakeDetectionPresenter(
mutableStateOf(false) mutableStateOf(false)
} }
fun handleEvent(event: RageshakeDetectionEvents) { fun handleEvent(event: RageshakeDetectionEvent) {
when (event) { when (event) {
RageshakeDetectionEvents.Disable -> { RageshakeDetectionEvent.Disable -> {
preferencesState.eventSink(RageshakePreferencesEvents.SetIsEnabled(false)) preferencesState.eventSink(RageshakePreferencesEvent.SetIsEnabled(false))
showDialog.value = false showDialog.value = false
} }
RageshakeDetectionEvents.StartDetection -> isStarted.value = true RageshakeDetectionEvent.StartDetection -> isStarted.value = true
RageshakeDetectionEvents.StopDetection -> isStarted.value = false RageshakeDetectionEvent.StopDetection -> isStarted.value = false
is RageshakeDetectionEvents.ProcessScreenshot -> localCoroutineScope.processScreenshot(takeScreenshot, showDialog, event.imageResult) is RageshakeDetectionEvent.ProcessScreenshot -> localCoroutineScope.processScreenshot(takeScreenshot, showDialog, event.imageResult)
RageshakeDetectionEvents.Dismiss -> showDialog.value = false RageshakeDetectionEvent.Dismiss -> showDialog.value = false
} }
} }
val state = remember(preferencesState, isStarted.value, takeScreenshot.value, showDialog.value) {
RageshakeDetectionState(
isStarted = isStarted.value,
takeScreenshot = takeScreenshot.value,
showDialog = showDialog.value,
preferenceState = preferencesState,
eventSink = ::handleEvent,
)
}
LaunchedEffect(preferencesState.sensitivity) { LaunchedEffect(preferencesState.sensitivity) {
rageShake.setSensitivity(preferencesState.sensitivity) rageShake.setSensitivity(preferencesState.sensitivity)
} }
@ -83,14 +72,25 @@ class DefaultRageshakeDetectionPresenter(
!showDialog.value !showDialog.value
LaunchedEffect(shouldStart) { LaunchedEffect(shouldStart) {
handleRageShake(shouldStart, state, takeScreenshot) handleRageShake(
start = shouldStart,
sensitivity = preferencesState.sensitivity,
takeScreenshot = takeScreenshot,
)
} }
return state
return RageshakeDetectionState(
isStarted = isStarted.value,
takeScreenshot = takeScreenshot.value,
showDialog = showDialog.value,
preferenceState = preferencesState,
eventSink = ::handleEvent,
)
} }
private fun handleRageShake(start: Boolean, state: RageshakeDetectionState, takeScreenshot: MutableState<Boolean>) { private fun handleRageShake(start: Boolean, sensitivity: Float, takeScreenshot: MutableState<Boolean>) {
if (start) { if (start) {
rageShake.start(state.preferenceState.sensitivity) rageShake.start(sensitivity)
rageShake.setInterceptor { rageShake.setInterceptor {
takeScreenshot.value = true takeScreenshot.value = true
} }

View file

@ -19,7 +19,7 @@ import androidx.compose.runtime.saveable.rememberSaveable
import dev.zacsweers.metro.AppScope import dev.zacsweers.metro.AppScope
import dev.zacsweers.metro.ContributesBinding import dev.zacsweers.metro.ContributesBinding
import io.element.android.features.rageshake.api.RageshakeFeatureAvailability import io.element.android.features.rageshake.api.RageshakeFeatureAvailability
import io.element.android.features.rageshake.api.preferences.RageshakePreferencesEvents import io.element.android.features.rageshake.api.preferences.RageshakePreferencesEvent
import io.element.android.features.rageshake.api.preferences.RageshakePreferencesPresenter import io.element.android.features.rageshake.api.preferences.RageshakePreferencesPresenter
import io.element.android.features.rageshake.api.preferences.RageshakePreferencesState import io.element.android.features.rageshake.api.preferences.RageshakePreferencesState
import io.element.android.features.rageshake.impl.rageshake.RageShake import io.element.android.features.rageshake.impl.rageshake.RageShake
@ -48,10 +48,10 @@ class DefaultRageshakePreferencesPresenter(
rageshakeDataStore.sensitivity() rageshakeDataStore.sensitivity()
}.collectAsState(initial = 0f) }.collectAsState(initial = 0f)
fun handleEvent(event: RageshakePreferencesEvents) { fun handleEvent(event: RageshakePreferencesEvent) {
when (event) { when (event) {
is RageshakePreferencesEvents.SetIsEnabled -> localCoroutineScope.setIsEnabled(event.isEnabled) is RageshakePreferencesEvent.SetIsEnabled -> localCoroutineScope.setIsEnabled(event.isEnabled)
is RageshakePreferencesEvents.SetSensitivity -> localCoroutineScope.setSensitivity(event.sensitivity) is RageshakePreferencesEvent.SetSensitivity -> localCoroutineScope.setSensitivity(event.sensitivity)
} }
} }

View file

@ -8,9 +8,6 @@
package io.element.android.features.rageshake.impl.bugreport package io.element.android.features.rageshake.impl.bugreport
import app.cash.molecule.RecompositionMode
import app.cash.molecule.moleculeFlow
import app.cash.turbine.test
import com.google.common.truth.Truth.assertThat import com.google.common.truth.Truth.assertThat
import io.element.android.features.rageshake.api.reporter.BugReporter import io.element.android.features.rageshake.api.reporter.BugReporter
import io.element.android.features.rageshake.impl.crash.A_CRASH_DATA import io.element.android.features.rageshake.impl.crash.A_CRASH_DATA
@ -22,6 +19,7 @@ import io.element.android.features.rageshake.impl.screenshot.ScreenshotHolder
import io.element.android.libraries.architecture.AsyncAction import io.element.android.libraries.architecture.AsyncAction
import io.element.android.libraries.matrix.test.A_FAILURE_REASON import io.element.android.libraries.matrix.test.A_FAILURE_REASON
import io.element.android.tests.testutils.WarmUpRule import io.element.android.tests.testutils.WarmUpRule
import io.element.android.tests.testutils.test
import kotlinx.coroutines.test.TestScope import kotlinx.coroutines.test.TestScope
import kotlinx.coroutines.test.runTest import kotlinx.coroutines.test.runTest
import org.junit.Rule import org.junit.Rule
@ -37,9 +35,7 @@ class BugReportPresenterTest {
@Test @Test
fun `present - initial state`() = runTest { fun `present - initial state`() = runTest {
val presenter = createPresenter() val presenter = createPresenter()
moleculeFlow(RecompositionMode.Immediate) { presenter.test {
presenter.present()
}.test {
val initialState = awaitItem() val initialState = awaitItem()
assertThat(initialState.hasCrashLogs).isFalse() assertThat(initialState.hasCrashLogs).isFalse()
assertThat(initialState.formState).isEqualTo(BugReportFormState.Default) assertThat(initialState.formState).isEqualTo(BugReportFormState.Default)
@ -53,9 +49,7 @@ class BugReportPresenterTest {
@Test @Test
fun `present - set description`() = runTest { fun `present - set description`() = runTest {
val presenter = createPresenter() val presenter = createPresenter()
moleculeFlow(RecompositionMode.Immediate) { presenter.test {
presenter.present()
}.test {
val initialState = awaitItem() val initialState = awaitItem()
initialState.eventSink.invoke(BugReportEvents.SetDescription(A_SHORT_DESCRIPTION)) initialState.eventSink.invoke(BugReportEvents.SetDescription(A_SHORT_DESCRIPTION))
assertThat(awaitItem().submitEnabled).isTrue() assertThat(awaitItem().submitEnabled).isTrue()
@ -67,9 +61,7 @@ class BugReportPresenterTest {
@Test @Test
fun `present - can contact`() = runTest { fun `present - can contact`() = runTest {
val presenter = createPresenter() val presenter = createPresenter()
moleculeFlow(RecompositionMode.Immediate) { presenter.test {
presenter.present()
}.test {
val initialState = awaitItem() val initialState = awaitItem()
initialState.eventSink.invoke(BugReportEvents.SetCanContact(true)) initialState.eventSink.invoke(BugReportEvents.SetCanContact(true))
assertThat(awaitItem().formState).isEqualTo(BugReportFormState.Default.copy(canContact = true)) assertThat(awaitItem().formState).isEqualTo(BugReportFormState.Default.copy(canContact = true))
@ -81,9 +73,7 @@ class BugReportPresenterTest {
@Test @Test
fun `present - send logs`() = runTest { fun `present - send logs`() = runTest {
val presenter = createPresenter() val presenter = createPresenter()
moleculeFlow(RecompositionMode.Immediate) { presenter.test {
presenter.present()
}.test {
val initialState = awaitItem() val initialState = awaitItem()
// Since this is true by default, start by disabling // Since this is true by default, start by disabling
initialState.eventSink.invoke(BugReportEvents.SetSendLog(false)) initialState.eventSink.invoke(BugReportEvents.SetSendLog(false))
@ -96,9 +86,7 @@ class BugReportPresenterTest {
@Test @Test
fun `present - send screenshot`() = runTest { fun `present - send screenshot`() = runTest {
val presenter = createPresenter() val presenter = createPresenter()
moleculeFlow(RecompositionMode.Immediate) { presenter.test {
presenter.present()
}.test {
val initialState = awaitItem() val initialState = awaitItem()
initialState.eventSink.invoke(BugReportEvents.SetSendScreenshot(true)) initialState.eventSink.invoke(BugReportEvents.SetSendScreenshot(true))
assertThat(awaitItem().formState).isEqualTo(BugReportFormState.Default.copy(sendScreenshot = true)) assertThat(awaitItem().formState).isEqualTo(BugReportFormState.Default.copy(sendScreenshot = true))
@ -110,9 +98,7 @@ class BugReportPresenterTest {
@Test @Test
fun `present - send notification settings`() = runTest { fun `present - send notification settings`() = runTest {
val presenter = createPresenter() val presenter = createPresenter()
moleculeFlow(RecompositionMode.Immediate) { presenter.test {
presenter.present()
}.test {
val initialState = awaitItem() val initialState = awaitItem()
initialState.eventSink.invoke(BugReportEvents.SetSendPushRules(true)) initialState.eventSink.invoke(BugReportEvents.SetSendPushRules(true))
assertThat(awaitItem().formState).isEqualTo(BugReportFormState.Default.copy(sendPushRules = true)) assertThat(awaitItem().formState).isEqualTo(BugReportFormState.Default.copy(sendPushRules = true))
@ -127,9 +113,7 @@ class BugReportPresenterTest {
crashDataStore = FakeCrashDataStore(crashData = A_CRASH_DATA, appHasCrashed = true), crashDataStore = FakeCrashDataStore(crashData = A_CRASH_DATA, appHasCrashed = true),
screenshotHolder = FakeScreenshotHolder(screenshotUri = A_SCREENSHOT_URI), screenshotHolder = FakeScreenshotHolder(screenshotUri = A_SCREENSHOT_URI),
) )
moleculeFlow(RecompositionMode.Immediate) { presenter.test {
presenter.present()
}.test {
skipItems(1) skipItems(1)
val initialState = awaitItem() val initialState = awaitItem()
assertThat(initialState.hasCrashLogs).isTrue() assertThat(initialState.hasCrashLogs).isTrue()
@ -148,9 +132,7 @@ class BugReportPresenterTest {
FakeCrashDataStore(crashData = A_CRASH_DATA, appHasCrashed = true), FakeCrashDataStore(crashData = A_CRASH_DATA, appHasCrashed = true),
FakeScreenshotHolder(screenshotUri = A_SCREENSHOT_URI), FakeScreenshotHolder(screenshotUri = A_SCREENSHOT_URI),
) )
moleculeFlow(RecompositionMode.Immediate) { presenter.test {
presenter.present()
}.test {
val initialState = awaitItem() val initialState = awaitItem()
initialState.eventSink.invoke(BugReportEvents.SetDescription(A_LONG_DESCRIPTION)) initialState.eventSink.invoke(BugReportEvents.SetDescription(A_LONG_DESCRIPTION))
skipItems(1) skipItems(1)
@ -174,9 +156,7 @@ class BugReportPresenterTest {
FakeCrashDataStore(crashData = A_CRASH_DATA, appHasCrashed = true), FakeCrashDataStore(crashData = A_CRASH_DATA, appHasCrashed = true),
FakeScreenshotHolder(screenshotUri = A_SCREENSHOT_URI), FakeScreenshotHolder(screenshotUri = A_SCREENSHOT_URI),
) )
moleculeFlow(RecompositionMode.Immediate) { presenter.test {
presenter.present()
}.test {
val initialState = awaitItem() val initialState = awaitItem()
initialState.eventSink.invoke(BugReportEvents.SetDescription(A_LONG_DESCRIPTION)) initialState.eventSink.invoke(BugReportEvents.SetDescription(A_LONG_DESCRIPTION))
skipItems(1) skipItems(1)
@ -200,9 +180,7 @@ class BugReportPresenterTest {
@Test @Test
fun `present - send failure description too short`() = runTest { fun `present - send failure description too short`() = runTest {
val presenter = createPresenter() val presenter = createPresenter()
moleculeFlow(RecompositionMode.Immediate) { presenter.test {
presenter.present()
}.test {
val initialState = awaitItem() val initialState = awaitItem()
initialState.eventSink.invoke(BugReportEvents.SetDescription(A_SHORT_DESCRIPTION)) initialState.eventSink.invoke(BugReportEvents.SetDescription(A_SHORT_DESCRIPTION))
skipItems(1) skipItems(1)
@ -223,9 +201,7 @@ class BugReportPresenterTest {
FakeCrashDataStore(crashData = A_CRASH_DATA, appHasCrashed = true), FakeCrashDataStore(crashData = A_CRASH_DATA, appHasCrashed = true),
FakeScreenshotHolder(screenshotUri = A_SCREENSHOT_URI), FakeScreenshotHolder(screenshotUri = A_SCREENSHOT_URI),
) )
moleculeFlow(RecompositionMode.Immediate) { presenter.test {
presenter.present()
}.test {
val initialState = awaitItem() val initialState = awaitItem()
initialState.eventSink.invoke(BugReportEvents.SetDescription(A_LONG_DESCRIPTION)) initialState.eventSink.invoke(BugReportEvents.SetDescription(A_LONG_DESCRIPTION))
skipItems(1) skipItems(1)

View file

@ -8,17 +8,15 @@
package io.element.android.features.rageshake.impl.crash.ui package io.element.android.features.rageshake.impl.crash.ui
import app.cash.molecule.RecompositionMode
import app.cash.molecule.moleculeFlow
import app.cash.turbine.test
import com.google.common.truth.Truth.assertThat import com.google.common.truth.Truth.assertThat
import io.element.android.features.rageshake.api.crash.CrashDetectionEvents import io.element.android.features.rageshake.api.crash.CrashDetectionEvent
import io.element.android.features.rageshake.impl.crash.A_CRASH_DATA import io.element.android.features.rageshake.impl.crash.A_CRASH_DATA
import io.element.android.features.rageshake.impl.crash.DefaultCrashDetectionPresenter import io.element.android.features.rageshake.impl.crash.DefaultCrashDetectionPresenter
import io.element.android.features.rageshake.impl.crash.FakeCrashDataStore import io.element.android.features.rageshake.impl.crash.FakeCrashDataStore
import io.element.android.libraries.core.meta.BuildMeta import io.element.android.libraries.core.meta.BuildMeta
import io.element.android.libraries.matrix.test.core.aBuildMeta import io.element.android.libraries.matrix.test.core.aBuildMeta
import io.element.android.tests.testutils.WarmUpRule import io.element.android.tests.testutils.WarmUpRule
import io.element.android.tests.testutils.test
import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.flowOf import kotlinx.coroutines.flow.flowOf
@ -33,9 +31,7 @@ class CrashDetectionPresenterTest {
@Test @Test
fun `present - initial state no crash`() = runTest { fun `present - initial state no crash`() = runTest {
val presenter = createPresenter() val presenter = createPresenter()
moleculeFlow(RecompositionMode.Immediate) { presenter.test {
presenter.present()
}.test {
val initialState = awaitItem() val initialState = awaitItem()
assertThat(initialState.crashDetected).isFalse() assertThat(initialState.crashDetected).isFalse()
} }
@ -46,9 +42,7 @@ class CrashDetectionPresenterTest {
val presenter = createPresenter( val presenter = createPresenter(
FakeCrashDataStore(appHasCrashed = true) FakeCrashDataStore(appHasCrashed = true)
) )
moleculeFlow(RecompositionMode.Immediate) { presenter.test {
presenter.present()
}.test {
skipItems(1) skipItems(1)
val initialState = awaitItem() val initialState = awaitItem()
assertThat(initialState.crashDetected).isTrue() assertThat(initialState.crashDetected).isTrue()
@ -61,9 +55,7 @@ class CrashDetectionPresenterTest {
FakeCrashDataStore(appHasCrashed = true), FakeCrashDataStore(appHasCrashed = true),
isFeatureAvailableFlow = flowOf(false), isFeatureAvailableFlow = flowOf(false),
) )
moleculeFlow(RecompositionMode.Immediate) { presenter.test {
presenter.present()
}.test {
val initialState = awaitItem() val initialState = awaitItem()
assertThat(initialState.crashDetected).isFalse() assertThat(initialState.crashDetected).isFalse()
} }
@ -74,13 +66,11 @@ class CrashDetectionPresenterTest {
val presenter = createPresenter( val presenter = createPresenter(
FakeCrashDataStore(appHasCrashed = true) FakeCrashDataStore(appHasCrashed = true)
) )
moleculeFlow(RecompositionMode.Immediate) { presenter.test {
presenter.present()
}.test {
skipItems(1) skipItems(1)
val initialState = awaitItem() val initialState = awaitItem()
assertThat(initialState.crashDetected).isTrue() assertThat(initialState.crashDetected).isTrue()
initialState.eventSink.invoke(CrashDetectionEvents.ResetAppHasCrashed) initialState.eventSink.invoke(CrashDetectionEvent.ResetAppHasCrashed)
assertThat(awaitItem().crashDetected).isFalse() assertThat(awaitItem().crashDetected).isFalse()
} }
} }
@ -90,13 +80,11 @@ class CrashDetectionPresenterTest {
val presenter = createPresenter( val presenter = createPresenter(
FakeCrashDataStore(appHasCrashed = true, crashData = A_CRASH_DATA) FakeCrashDataStore(appHasCrashed = true, crashData = A_CRASH_DATA)
) )
moleculeFlow(RecompositionMode.Immediate) { presenter.test {
presenter.present()
}.test {
skipItems(1) skipItems(1)
val initialState = awaitItem() val initialState = awaitItem()
assertThat(initialState.crashDetected).isTrue() assertThat(initialState.crashDetected).isTrue()
initialState.eventSink.invoke(CrashDetectionEvents.ResetAllCrashData) initialState.eventSink.invoke(CrashDetectionEvent.ResetAllCrashData)
assertThat(awaitItem().crashDetected).isFalse() assertThat(awaitItem().crashDetected).isFalse()
} }
} }
@ -109,9 +97,7 @@ class CrashDetectionPresenterTest {
crashDataStore = crashDataStore, crashDataStore = crashDataStore,
isFeatureAvailableFlow = isFeatureAvailableFlow, isFeatureAvailableFlow = isFeatureAvailableFlow,
) )
moleculeFlow(RecompositionMode.Immediate) { presenter.test {
presenter.present()
}.test {
val initialState = awaitItem() val initialState = awaitItem()
assertThat(initialState.crashDetected).isFalse() assertThat(initialState.crashDetected).isFalse()
crashDataStore.setCrashData("Some crash data") crashDataStore.setCrashData("Some crash data")

View file

@ -9,11 +9,8 @@
package io.element.android.features.rageshake.impl.detection package io.element.android.features.rageshake.impl.detection
import android.graphics.Bitmap import android.graphics.Bitmap
import app.cash.molecule.RecompositionMode
import app.cash.molecule.moleculeFlow
import app.cash.turbine.test
import com.google.common.truth.Truth.assertThat import com.google.common.truth.Truth.assertThat
import io.element.android.features.rageshake.api.detection.RageshakeDetectionEvents import io.element.android.features.rageshake.api.detection.RageshakeDetectionEvent
import io.element.android.features.rageshake.api.screenshot.ImageResult import io.element.android.features.rageshake.api.screenshot.ImageResult
import io.element.android.features.rageshake.impl.preferences.DefaultRageshakePreferencesPresenter import io.element.android.features.rageshake.impl.preferences.DefaultRageshakePreferencesPresenter
import io.element.android.features.rageshake.impl.rageshake.FakeRageShake import io.element.android.features.rageshake.impl.rageshake.FakeRageShake
@ -21,6 +18,7 @@ import io.element.android.features.rageshake.impl.rageshake.FakeRageshakeDataSto
import io.element.android.features.rageshake.impl.screenshot.FakeScreenshotHolder import io.element.android.features.rageshake.impl.screenshot.FakeScreenshotHolder
import io.element.android.libraries.matrix.test.AN_EXCEPTION import io.element.android.libraries.matrix.test.AN_EXCEPTION
import io.element.android.tests.testutils.WarmUpRule import io.element.android.tests.testutils.WarmUpRule
import io.element.android.tests.testutils.test
import io.mockk.mockk import io.mockk.mockk
import kotlinx.coroutines.flow.first import kotlinx.coroutines.flow.first
import kotlinx.coroutines.flow.flowOf import kotlinx.coroutines.flow.flowOf
@ -57,9 +55,7 @@ class RageshakeDetectionPresenterTest {
rageshakeFeatureAvailability = { flowOf(true) }, rageshakeFeatureAvailability = { flowOf(true) },
) )
) )
moleculeFlow(RecompositionMode.Immediate) { presenter.test {
presenter.present()
}.test {
skipItems(1) skipItems(1)
val initialState = awaitItem() val initialState = awaitItem()
assertThat(initialState.takeScreenshot).isFalse() assertThat(initialState.takeScreenshot).isFalse()
@ -82,14 +78,12 @@ class RageshakeDetectionPresenterTest {
rageshakeFeatureAvailability = { flowOf(true) }, rageshakeFeatureAvailability = { flowOf(true) },
) )
) )
moleculeFlow(RecompositionMode.Immediate) { presenter.test {
presenter.present()
}.test {
skipItems(1) skipItems(1)
val initialState = awaitItem() val initialState = awaitItem()
initialState.eventSink.invoke(RageshakeDetectionEvents.StartDetection) initialState.eventSink.invoke(RageshakeDetectionEvent.StartDetection)
assertThat(awaitItem().isStarted).isTrue() assertThat(awaitItem().isStarted).isTrue()
initialState.eventSink.invoke(RageshakeDetectionEvents.StopDetection) initialState.eventSink.invoke(RageshakeDetectionEvent.StopDetection)
assertThat(awaitItem().isStarted).isFalse() assertThat(awaitItem().isStarted).isFalse()
} }
} }
@ -108,21 +102,19 @@ class RageshakeDetectionPresenterTest {
rageshakeFeatureAvailability = { flowOf(true) }, rageshakeFeatureAvailability = { flowOf(true) },
) )
) )
moleculeFlow(RecompositionMode.Immediate) { presenter.test {
presenter.present()
}.test {
skipItems(1) skipItems(1)
val initialState = awaitItem() val initialState = awaitItem()
assertThat(initialState.isStarted).isFalse() assertThat(initialState.isStarted).isFalse()
initialState.eventSink.invoke(RageshakeDetectionEvents.StartDetection) initialState.eventSink.invoke(RageshakeDetectionEvent.StartDetection)
assertThat(awaitItem().isStarted).isTrue() assertThat(awaitItem().isStarted).isTrue()
rageshake.triggerPhoneRageshake() rageshake.triggerPhoneRageshake()
assertThat(awaitItem().takeScreenshot).isTrue() assertThat(awaitItem().takeScreenshot).isTrue()
initialState.eventSink.invoke( initialState.eventSink.invoke(
RageshakeDetectionEvents.ProcessScreenshot(ImageResult.Success(aBitmap)) RageshakeDetectionEvent.ProcessScreenshot(ImageResult.Success(aBitmap))
) )
assertThat(awaitItem().showDialog).isTrue() assertThat(awaitItem().showDialog).isTrue()
initialState.eventSink.invoke(RageshakeDetectionEvents.Dismiss) initialState.eventSink.invoke(RageshakeDetectionEvent.Dismiss)
val finalState = awaitItem() val finalState = awaitItem()
assertThat(finalState.showDialog).isFalse() assertThat(finalState.showDialog).isFalse()
assertThat(rageshakeDataStore.isEnabled().first()).isTrue() assertThat(rageshakeDataStore.isEnabled().first()).isTrue()
@ -143,21 +135,19 @@ class RageshakeDetectionPresenterTest {
rageshakeFeatureAvailability = { flowOf(true) }, rageshakeFeatureAvailability = { flowOf(true) },
) )
) )
moleculeFlow(RecompositionMode.Immediate) { presenter.test {
presenter.present()
}.test {
skipItems(1) skipItems(1)
val initialState = awaitItem() val initialState = awaitItem()
assertThat(initialState.isStarted).isFalse() assertThat(initialState.isStarted).isFalse()
initialState.eventSink.invoke(RageshakeDetectionEvents.StartDetection) initialState.eventSink.invoke(RageshakeDetectionEvent.StartDetection)
assertThat(awaitItem().isStarted).isTrue() assertThat(awaitItem().isStarted).isTrue()
rageshake.triggerPhoneRageshake() rageshake.triggerPhoneRageshake()
assertThat(awaitItem().takeScreenshot).isTrue() assertThat(awaitItem().takeScreenshot).isTrue()
initialState.eventSink.invoke( initialState.eventSink.invoke(
RageshakeDetectionEvents.ProcessScreenshot(ImageResult.Error(AN_EXCEPTION)) RageshakeDetectionEvent.ProcessScreenshot(ImageResult.Error(AN_EXCEPTION))
) )
assertThat(awaitItem().showDialog).isTrue() assertThat(awaitItem().showDialog).isTrue()
initialState.eventSink.invoke(RageshakeDetectionEvents.Dismiss) initialState.eventSink.invoke(RageshakeDetectionEvent.Dismiss)
val finalState = awaitItem() val finalState = awaitItem()
assertThat(finalState.showDialog).isFalse() assertThat(finalState.showDialog).isFalse()
assertThat(rageshakeDataStore.isEnabled().first()).isTrue() assertThat(rageshakeDataStore.isEnabled().first()).isTrue()
@ -178,21 +168,19 @@ class RageshakeDetectionPresenterTest {
rageshakeFeatureAvailability = { flowOf(true) }, rageshakeFeatureAvailability = { flowOf(true) },
) )
) )
moleculeFlow(RecompositionMode.Immediate) { presenter.test {
presenter.present()
}.test {
skipItems(1) skipItems(1)
val initialState = awaitItem() val initialState = awaitItem()
assertThat(initialState.isStarted).isFalse() assertThat(initialState.isStarted).isFalse()
initialState.eventSink.invoke(RageshakeDetectionEvents.StartDetection) initialState.eventSink.invoke(RageshakeDetectionEvent.StartDetection)
assertThat(awaitItem().isStarted).isTrue() assertThat(awaitItem().isStarted).isTrue()
rageshake.triggerPhoneRageshake() rageshake.triggerPhoneRageshake()
assertThat(awaitItem().takeScreenshot).isTrue() assertThat(awaitItem().takeScreenshot).isTrue()
initialState.eventSink.invoke( initialState.eventSink.invoke(
RageshakeDetectionEvents.ProcessScreenshot(ImageResult.Success(aBitmap)) RageshakeDetectionEvent.ProcessScreenshot(ImageResult.Success(aBitmap))
) )
assertThat(awaitItem().showDialog).isTrue() assertThat(awaitItem().showDialog).isTrue()
initialState.eventSink.invoke(RageshakeDetectionEvents.Disable) initialState.eventSink.invoke(RageshakeDetectionEvent.Disable)
skipItems(1) skipItems(1)
assertThat(awaitItem().showDialog).isFalse() assertThat(awaitItem().showDialog).isFalse()
assertThat(rageshakeDataStore.isEnabled().first()).isFalse() assertThat(rageshakeDataStore.isEnabled().first()).isFalse()

View file

@ -8,15 +8,13 @@
package io.element.android.features.rageshake.impl.preferences package io.element.android.features.rageshake.impl.preferences
import app.cash.molecule.RecompositionMode
import app.cash.molecule.moleculeFlow
import app.cash.turbine.test
import com.google.common.truth.Truth.assertThat import com.google.common.truth.Truth.assertThat
import io.element.android.features.rageshake.api.preferences.RageshakePreferencesEvents import io.element.android.features.rageshake.api.preferences.RageshakePreferencesEvent
import io.element.android.features.rageshake.impl.rageshake.A_SENSITIVITY import io.element.android.features.rageshake.impl.rageshake.A_SENSITIVITY
import io.element.android.features.rageshake.impl.rageshake.FakeRageShake import io.element.android.features.rageshake.impl.rageshake.FakeRageShake
import io.element.android.features.rageshake.impl.rageshake.FakeRageshakeDataStore import io.element.android.features.rageshake.impl.rageshake.FakeRageshakeDataStore
import io.element.android.tests.testutils.WarmUpRule import io.element.android.tests.testutils.WarmUpRule
import io.element.android.tests.testutils.test
import kotlinx.coroutines.flow.flowOf import kotlinx.coroutines.flow.flowOf
import kotlinx.coroutines.test.runTest import kotlinx.coroutines.test.runTest
import org.junit.Rule import org.junit.Rule
@ -33,9 +31,7 @@ class RageshakePreferencesPresenterTest {
FakeRageshakeDataStore(isEnabled = true), FakeRageshakeDataStore(isEnabled = true),
rageshakeFeatureAvailability = { flowOf(true) }, rageshakeFeatureAvailability = { flowOf(true) },
) )
moleculeFlow(RecompositionMode.Immediate) { presenter.test {
presenter.present()
}.test {
skipItems(1) skipItems(1)
val initialState = awaitItem() val initialState = awaitItem()
assertThat(initialState.isSupported).isTrue() assertThat(initialState.isSupported).isTrue()
@ -50,9 +46,7 @@ class RageshakePreferencesPresenterTest {
FakeRageshakeDataStore(isEnabled = true), FakeRageshakeDataStore(isEnabled = true),
rageshakeFeatureAvailability = { flowOf(true) }, rageshakeFeatureAvailability = { flowOf(true) },
) )
moleculeFlow(RecompositionMode.Immediate) { presenter.test {
presenter.present()
}.test {
skipItems(1) skipItems(1)
val initialState = awaitItem() val initialState = awaitItem()
assertThat(initialState.isSupported).isFalse() assertThat(initialState.isSupported).isFalse()
@ -67,15 +61,13 @@ class RageshakePreferencesPresenterTest {
FakeRageshakeDataStore(isEnabled = true), FakeRageshakeDataStore(isEnabled = true),
rageshakeFeatureAvailability = { flowOf(true) }, rageshakeFeatureAvailability = { flowOf(true) },
) )
moleculeFlow(RecompositionMode.Immediate) { presenter.test {
presenter.present()
}.test {
skipItems(1) skipItems(1)
val initialState = awaitItem() val initialState = awaitItem()
assertThat(initialState.isEnabled).isTrue() assertThat(initialState.isEnabled).isTrue()
initialState.eventSink.invoke(RageshakePreferencesEvents.SetIsEnabled(false)) initialState.eventSink.invoke(RageshakePreferencesEvent.SetIsEnabled(false))
assertThat(awaitItem().isEnabled).isFalse() assertThat(awaitItem().isEnabled).isFalse()
initialState.eventSink.invoke(RageshakePreferencesEvents.SetIsEnabled(true)) initialState.eventSink.invoke(RageshakePreferencesEvent.SetIsEnabled(true))
assertThat(awaitItem().isEnabled).isTrue() assertThat(awaitItem().isEnabled).isTrue()
} }
} }
@ -87,13 +79,11 @@ class RageshakePreferencesPresenterTest {
FakeRageshakeDataStore(isEnabled = true), FakeRageshakeDataStore(isEnabled = true),
rageshakeFeatureAvailability = { flowOf(true) }, rageshakeFeatureAvailability = { flowOf(true) },
) )
moleculeFlow(RecompositionMode.Immediate) { presenter.test {
presenter.present()
}.test {
skipItems(1) skipItems(1)
val initialState = awaitItem() val initialState = awaitItem()
assertThat(initialState.sensitivity).isEqualTo(A_SENSITIVITY) assertThat(initialState.sensitivity).isEqualTo(A_SENSITIVITY)
initialState.eventSink.invoke(RageshakePreferencesEvents.SetSensitivity(A_SENSITIVITY + 1f)) initialState.eventSink.invoke(RageshakePreferencesEvent.SetSensitivity(A_SENSITIVITY + 1f))
assertThat(awaitItem().sensitivity).isEqualTo(A_SENSITIVITY + 1f) assertThat(awaitItem().sensitivity).isEqualTo(A_SENSITIVITY + 1f)
} }
} }