Merge branch 'develop' into feature/valere/call/decline_timeline_rendering

This commit is contained in:
Valere 2026-05-04 09:00:00 +02:00
commit 4c761812c6
171 changed files with 2932 additions and 3196 deletions

3
.idea/kotlinc.xml generated
View file

@ -1,6 +1,7 @@
<?xml version="1.0" encoding="UTF-8"?> <?xml version="1.0" encoding="UTF-8"?>
<project version="4"> <project version="4">
<component name="KotlinJpsPluginSettings"> <component name="KotlinJpsPluginSettings">
<option name="version" value="2.3.20" /> <option name="externalSystemId" value="Gradle" />
<option name="version" value="2.3.21" />
</component> </component>
</project> </project>

View file

@ -48,6 +48,8 @@ android {
} }
dependencies { dependencies {
implementation(libs.coroutines.core)
implementation(libs.androidx.annotationjvm) implementation(libs.androidx.annotationjvm)
implementation(libs.androidx.corektx)
implementation(projects.libraries.matrix.api) implementation(projects.libraries.matrix.api)
} }

View file

@ -40,6 +40,7 @@ dependencies {
implementation(projects.libraries.designsystem) implementation(projects.libraries.designsystem)
implementation(projects.libraries.matrixui) implementation(projects.libraries.matrixui)
implementation(projects.libraries.matrixmedia.api) implementation(projects.libraries.matrixmedia.api)
implementation(projects.libraries.sessionStorage.api)
implementation(projects.libraries.uiCommon) implementation(projects.libraries.uiCommon)
implementation(projects.libraries.uiStrings) implementation(projects.libraries.uiStrings)
implementation(projects.features.login.api) implementation(projects.features.login.api)

View file

@ -46,7 +46,7 @@ allprojects {
config.from(files("$rootDir/tools/detekt/detekt.yml")) config.from(files("$rootDir/tools/detekt/detekt.yml"))
} }
dependencies { dependencies {
detektPlugins("io.nlopez.compose.rules:detekt:0.5.7") detektPlugins("io.nlopez.compose.rules:detekt:0.5.8")
detektPlugins(project(":tests:detekt-rules")) detektPlugins(project(":tests:detekt-rules"))
} }

View file

@ -6,11 +6,14 @@
* Please see LICENSE files in the repository root for full details. * Please see LICENSE files in the repository root for full details.
*/ */
@file:OptIn(ExperimentalTestApi::class)
package io.element.android.features.announcement.impl.fullscreen package io.element.android.features.announcement.impl.fullscreen
import androidx.activity.ComponentActivity import androidx.activity.ComponentActivity
import androidx.compose.ui.test.junit4.AndroidComposeTestRule import androidx.compose.ui.test.AndroidComposeUiTest
import androidx.compose.ui.test.junit4.createAndroidComposeRule import androidx.compose.ui.test.ExperimentalTestApi
import androidx.compose.ui.test.v2.runAndroidComposeUiTest
import androidx.test.ext.junit.runners.AndroidJUnit4 import androidx.test.ext.junit.runners.AndroidJUnit4
import io.element.android.features.announcement.api.Announcement import io.element.android.features.announcement.api.Announcement
import io.element.android.features.announcement.impl.AnnouncementEvent import io.element.android.features.announcement.impl.AnnouncementEvent
@ -20,43 +23,39 @@ import io.element.android.libraries.ui.strings.CommonStrings
import io.element.android.tests.testutils.EventsRecorder import io.element.android.tests.testutils.EventsRecorder
import io.element.android.tests.testutils.clickOn import io.element.android.tests.testutils.clickOn
import io.element.android.tests.testutils.pressBackKey import io.element.android.tests.testutils.pressBackKey
import org.junit.Rule
import org.junit.Test import org.junit.Test
import org.junit.rules.TestRule
import org.junit.runner.RunWith import org.junit.runner.RunWith
@RunWith(AndroidJUnit4::class) @RunWith(AndroidJUnit4::class)
class FullscreenAnnouncementViewTest { class FullscreenAnnouncementViewTest {
@get:Rule val rule = createAndroidComposeRule<ComponentActivity>()
@Test @Test
fun `clicking on back sends a AnnouncementEvent`() { fun `clicking on back sends a AnnouncementEvent`() = runAndroidComposeUiTest {
val eventsRecorder = EventsRecorder<AnnouncementEvent>() val eventsRecorder = EventsRecorder<AnnouncementEvent>()
rule.setFullscreenAnnouncementView( setFullscreenAnnouncementView(
anAnnouncementState( anAnnouncementState(
announcement = Announcement.Fullscreen.Space, announcement = Announcement.Fullscreen.Space,
eventSink = eventsRecorder, eventSink = eventsRecorder,
), ),
) )
rule.pressBackKey() pressBackKey()
eventsRecorder.assertSingle(AnnouncementEvent.Continue(Announcement.Fullscreen.Space)) eventsRecorder.assertSingle(AnnouncementEvent.Continue(Announcement.Fullscreen.Space))
} }
@Test @Test
fun `clicking on Continue sends a AnnouncementEvent`() { fun `clicking on Continue sends a AnnouncementEvent`() = runAndroidComposeUiTest {
val eventsRecorder = EventsRecorder<AnnouncementEvent>() val eventsRecorder = EventsRecorder<AnnouncementEvent>()
rule.setFullscreenAnnouncementView( setFullscreenAnnouncementView(
anAnnouncementState( anAnnouncementState(
announcement = Announcement.Fullscreen.Space, announcement = Announcement.Fullscreen.Space,
eventSink = eventsRecorder, eventSink = eventsRecorder,
), ),
) )
rule.clickOn(CommonStrings.action_continue) clickOn(CommonStrings.action_continue)
eventsRecorder.assertSingle(AnnouncementEvent.Continue(Announcement.Fullscreen.Space)) eventsRecorder.assertSingle(AnnouncementEvent.Continue(Announcement.Fullscreen.Space))
} }
} }
private fun <R : TestRule> AndroidComposeTestRule<R, ComponentActivity>.setFullscreenAnnouncementView( private fun AndroidComposeUiTest<ComponentActivity>.setFullscreenAnnouncementView(
state: AnnouncementState, state: AnnouncementState,
) { ) {
setContent { setContent {

View file

@ -14,22 +14,9 @@ import io.element.android.libraries.matrix.api.core.RoomId
import io.element.android.libraries.matrix.api.core.SessionId import io.element.android.libraries.matrix.api.core.SessionId
import kotlinx.parcelize.Parcelize import kotlinx.parcelize.Parcelize
sealed interface CallType : NodeInputs, Parcelable { @Parcelize
@Parcelize data class CallData(
data class ExternalUrl(val url: String) : CallType { val sessionId: SessionId,
override fun toString(): String { val roomId: RoomId,
return "ExternalUrl" val isAudioCall: Boolean
} ) : NodeInputs, Parcelable
}
@Parcelize
data class RoomCall(
val sessionId: SessionId,
val roomId: RoomId,
val isAudioCall: Boolean
) : CallType {
override fun toString(): String {
return "RoomCall(sessionId=$sessionId, roomId=$roomId, isAudioCall=$isAudioCall)"
}
}
}

View file

@ -17,13 +17,13 @@ import io.element.android.libraries.matrix.api.core.UserId
interface ElementCallEntryPoint { interface ElementCallEntryPoint {
/** /**
* Start a call of the given type. * Start a call of the given type.
* @param callType The type of call to start. * @param callData The data of call to start.
*/ */
fun startCall(callType: CallType) fun startCall(callData: CallData)
/** /**
* Handle an incoming call. * Handle an incoming call.
* @param callType The type of call. * @param callData The data of call.
* @param eventId The event id of the event that started the call. * @param eventId The event id of the event that started the call.
* @param senderId The user id of the sender of the event that started the call. * @param senderId The user id of the sender of the event that started the call.
* @param roomName The name of the room the call is in. * @param roomName The name of the room the call is in.
@ -35,7 +35,7 @@ interface ElementCallEntryPoint {
* @param textContent The text content of the notification. If null the default content from the system will be used. * @param textContent The text content of the notification. If null the default content from the system will be used.
*/ */
suspend fun handleIncomingCall( suspend fun handleIncomingCall(
callType: CallType.RoomCall, callData: CallData,
eventId: EventId, eventId: EventId,
senderId: UserId, senderId: UserId,
roomName: String?, roomName: String?,

View file

@ -30,44 +30,10 @@
<activity <activity
android:name=".ui.ElementCallActivity" android:name=".ui.ElementCallActivity"
android:configChanges="screenSize|smallestScreenSize|screenLayout|orientation|keyboardHidden|keyboard|navigation|uiMode" android:configChanges="screenSize|smallestScreenSize|screenLayout|orientation|keyboardHidden|keyboard|navigation|uiMode"
android:exported="true"
android:label="@string/element_call" android:label="@string/element_call"
android:launchMode="singleTask" android:launchMode="singleTask"
android:supportsPictureInPicture="true" android:supportsPictureInPicture="true"
android:taskAffinity="io.element.android.features.call"> android:taskAffinity="io.element.android.features.call" />
<intent-filter android:autoVerify="true">
<action android:name="android.intent.action.VIEW" />
<category android:name="android.intent.category.DEFAULT" />
<category android:name="android.intent.category.BROWSABLE" />
<data android:scheme="https" />
<!-- Matching asset file: https://call.element.io/.well-known/assetlinks.json -->
<data android:host="call.element.io" />
</intent-filter>
<!-- Custom scheme to handle urls from other domains in the format: element://call?url=https%3A%2F%2Felement.io -->
<intent-filter>
<action android:name="android.intent.action.VIEW" />
<category android:name="android.intent.category.DEFAULT" />
<category android:name="android.intent.category.BROWSABLE" />
<data android:scheme="element" />
<data android:host="call" />
</intent-filter>
<!-- Custom scheme to handle urls from other domains in the format: io.element.call:/?url=https%3A%2F%2Felement.io -->
<intent-filter>
<action android:name="android.intent.action.VIEW" />
<category android:name="android.intent.category.DEFAULT" />
<category android:name="android.intent.category.BROWSABLE" />
<data android:scheme="io.element.call" />
</intent-filter>
</activity>
<activity <activity
android:name=".ui.IncomingCallActivity" android:name=".ui.IncomingCallActivity"

View file

@ -11,7 +11,7 @@ package io.element.android.features.call.impl
import android.content.Context import android.content.Context
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.call.api.CallType import io.element.android.features.call.api.CallData
import io.element.android.features.call.api.ElementCallEntryPoint import io.element.android.features.call.api.ElementCallEntryPoint
import io.element.android.features.call.impl.notifications.CallNotificationData import io.element.android.features.call.impl.notifications.CallNotificationData
import io.element.android.features.call.impl.utils.ActiveCallManager import io.element.android.features.call.impl.utils.ActiveCallManager
@ -30,12 +30,12 @@ class DefaultElementCallEntryPoint(
const val REQUEST_CODE = 2255 const val REQUEST_CODE = 2255
} }
override fun startCall(callType: CallType) { override fun startCall(callData: CallData) {
context.startActivity(IntentProvider.createIntent(context, callType)) context.startActivity(IntentProvider.createIntent(context, callData))
} }
override suspend fun handleIncomingCall( override suspend fun handleIncomingCall(
callType: CallType.RoomCall, callData: CallData,
eventId: EventId, eventId: EventId,
senderId: UserId, senderId: UserId,
roomName: String?, roomName: String?,
@ -47,8 +47,8 @@ class DefaultElementCallEntryPoint(
textContent: String?, textContent: String?,
) { ) {
val incomingCallNotificationData = CallNotificationData( val incomingCallNotificationData = CallNotificationData(
sessionId = callType.sessionId, sessionId = callData.sessionId,
roomId = callType.roomId, roomId = callData.roomId,
eventId = eventId, eventId = eventId,
senderId = senderId, senderId = senderId,
roomName = roomName, roomName = roomName,
@ -58,7 +58,7 @@ class DefaultElementCallEntryPoint(
expirationTimestamp = expirationTimestamp, expirationTimestamp = expirationTimestamp,
notificationChannelId = notificationChannelId, notificationChannelId = notificationChannelId,
textContent = textContent, textContent = textContent,
audioOnly = callType.isAudioCall audioOnly = callData.isAudioCall,
) )
activeCallManager.registerIncomingCall(notificationData = incomingCallNotificationData) activeCallManager.registerIncomingCall(notificationData = incomingCallNotificationData)
} }

View file

@ -18,7 +18,7 @@ import androidx.core.app.PendingIntentCompat
import androidx.core.app.Person import androidx.core.app.Person
import dev.zacsweers.metro.Inject import dev.zacsweers.metro.Inject
import io.element.android.appconfig.ElementCallConfig import io.element.android.appconfig.ElementCallConfig
import io.element.android.features.call.api.CallType import io.element.android.features.call.api.CallData
import io.element.android.features.call.impl.receivers.DeclineCallBroadcastReceiver import io.element.android.features.call.impl.receivers.DeclineCallBroadcastReceiver
import io.element.android.features.call.impl.ui.IncomingCallActivity import io.element.android.features.call.impl.ui.IncomingCallActivity
import io.element.android.features.call.impl.utils.IntentProvider import io.element.android.features.call.impl.utils.IntentProvider
@ -89,7 +89,14 @@ class RingingCallNotificationCreator(
.setImportant(true) .setImportant(true)
.build() .build()
val answerIntent = IntentProvider.getPendingIntent(context, CallType.RoomCall(sessionId, roomId, isAudioCall = audioOnly)) val answerIntent = IntentProvider.getPendingIntent(
context,
CallData(
sessionId = sessionId,
roomId = roomId,
isAudioCall = audioOnly,
),
)
val notificationData = CallNotificationData( val notificationData = CallNotificationData(
sessionId = sessionId, sessionId = sessionId,
roomId = roomId, roomId = roomId,

View file

@ -10,8 +10,8 @@ package io.element.android.features.call.impl.pip
import io.element.android.features.call.impl.utils.PipController import io.element.android.features.call.impl.utils.PipController
sealed interface PictureInPictureEvents { sealed interface PictureInPictureEvent {
data class SetPipController(val pipController: PipController) : PictureInPictureEvents data class SetPipController(val pipController: PipController) : PictureInPictureEvent
data object EnterPictureInPicture : PictureInPictureEvents data object EnterPictureInPicture : PictureInPictureEvent
data class OnPictureInPictureModeChanged(val isInPip: Boolean) : PictureInPictureEvents data class OnPictureInPictureModeChanged(val isInPip: Boolean) : PictureInPictureEvent
} }

View file

@ -36,17 +36,17 @@ class PictureInPicturePresenter(
var isInPictureInPicture by remember { mutableStateOf(false) } var isInPictureInPicture by remember { mutableStateOf(false) }
var pipController by remember { mutableStateOf<PipController?>(null) } var pipController by remember { mutableStateOf<PipController?>(null) }
fun handleEvent(event: PictureInPictureEvents) { fun handleEvent(event: PictureInPictureEvent) {
when (event) { when (event) {
is PictureInPictureEvents.SetPipController -> { is PictureInPictureEvent.SetPipController -> {
pipController = event.pipController pipController = event.pipController
} }
PictureInPictureEvents.EnterPictureInPicture -> { PictureInPictureEvent.EnterPictureInPicture -> {
coroutineScope.launch { coroutineScope.launch {
switchToPip(pipController) switchToPip(pipController)
} }
} }
is PictureInPictureEvents.OnPictureInPictureModeChanged -> { is PictureInPictureEvent.OnPictureInPictureModeChanged -> {
Timber.tag(loggerTag.value).d("onPictureInPictureModeChanged: ${event.isInPip}") Timber.tag(loggerTag.value).d("onPictureInPictureModeChanged: ${event.isInPip}")
isInPictureInPicture = event.isInPip isInPictureInPicture = event.isInPip
if (event.isInPip) { if (event.isInPip) {

View file

@ -11,5 +11,5 @@ package io.element.android.features.call.impl.pip
data class PictureInPictureState( data class PictureInPictureState(
val supportPip: Boolean, val supportPip: Boolean,
val isInPictureInPicture: Boolean, val isInPictureInPicture: Boolean,
val eventSink: (PictureInPictureEvents) -> Unit, val eventSink: (PictureInPictureEvent) -> Unit,
) )

View file

@ -11,7 +11,7 @@ package io.element.android.features.call.impl.pip
fun aPictureInPictureState( fun aPictureInPictureState(
supportPip: Boolean = false, supportPip: Boolean = false,
isInPictureInPicture: Boolean = false, isInPictureInPicture: Boolean = false,
eventSink: (PictureInPictureEvents) -> Unit = {}, eventSink: (PictureInPictureEvent) -> Unit = {},
): PictureInPictureState { ): PictureInPictureState {
return PictureInPictureState( return PictureInPictureState(
supportPip = supportPip, supportPip = supportPip,

View file

@ -13,7 +13,7 @@ import android.content.Context
import android.content.Intent import android.content.Intent
import androidx.core.content.IntentCompat import androidx.core.content.IntentCompat
import dev.zacsweers.metro.Inject import dev.zacsweers.metro.Inject
import io.element.android.features.call.api.CallType import io.element.android.features.call.api.CallData
import io.element.android.features.call.impl.di.CallBindings import io.element.android.features.call.impl.di.CallBindings
import io.element.android.features.call.impl.notifications.CallNotificationData import io.element.android.features.call.impl.notifications.CallNotificationData
import io.element.android.features.call.impl.utils.ActiveCallManager import io.element.android.features.call.impl.utils.ActiveCallManager
@ -42,7 +42,7 @@ class DeclineCallBroadcastReceiver : BroadcastReceiver() {
context.bindings<CallBindings>().inject(this) context.bindings<CallBindings>().inject(this)
appCoroutineScope.launch { appCoroutineScope.launch {
activeCallManager.hangUpCall( activeCallManager.hangUpCall(
callType = CallType.RoomCall( callData = CallData(
sessionId = notificationData.sessionId, sessionId = notificationData.sessionId,
roomId = notificationData.roomId, roomId = notificationData.roomId,
isAudioCall = notificationData.audioOnly isAudioCall = notificationData.audioOnly

View file

@ -0,0 +1,26 @@
/*
* Copyright (c) 2026 Element Creations Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
* Please see LICENSE files in the repository root for full details.
*/
package io.element.android.features.call.impl.ui
internal sealed interface CallScreenBackPressAction {
data object DispatchEscapeToWebView : CallScreenBackPressAction
data object EnterPictureInPicture : CallScreenBackPressAction
}
internal object CallScreenBackPressPolicy {
fun resolve(
supportPip: Boolean,
hasWebView: Boolean,
fromNative: Boolean,
): CallScreenBackPressAction? {
return when {
hasWebView && fromNative -> CallScreenBackPressAction.DispatchEscapeToWebView
hasWebView && supportPip -> CallScreenBackPressAction.EnterPictureInPicture
else -> null
}
}
}

View file

@ -10,8 +10,8 @@ package io.element.android.features.call.impl.ui
import io.element.android.features.call.impl.utils.WidgetMessageInterceptor import io.element.android.features.call.impl.utils.WidgetMessageInterceptor
sealed interface CallScreenEvents { sealed interface CallScreenEvent {
data object Hangup : CallScreenEvents data object Hangup : CallScreenEvent
data class SetupMessageChannels(val widgetMessageInterceptor: WidgetMessageInterceptor) : CallScreenEvents data class SetupMessageChannels(val widgetMessageInterceptor: WidgetMessageInterceptor) : CallScreenEvent
data class OnWebViewError(val description: String?) : CallScreenEvents data class OnWebViewError(val description: String?) : CallScreenEvent
} }

View file

@ -23,7 +23,7 @@ import dev.zacsweers.metro.AssistedFactory
import dev.zacsweers.metro.AssistedInject import dev.zacsweers.metro.AssistedInject
import im.vector.app.features.analytics.plan.MobileScreen import im.vector.app.features.analytics.plan.MobileScreen
import io.element.android.compound.theme.ElementTheme import io.element.android.compound.theme.ElementTheme
import io.element.android.features.call.api.CallType import io.element.android.features.call.api.CallData
import io.element.android.features.call.impl.data.WidgetMessage import io.element.android.features.call.impl.data.WidgetMessage
import io.element.android.features.call.impl.utils.ActiveCallManager import io.element.android.features.call.impl.utils.ActiveCallManager
import io.element.android.features.call.impl.utils.CallWidgetProvider import io.element.android.features.call.impl.utils.CallWidgetProvider
@ -52,7 +52,7 @@ import kotlin.time.Duration.Companion.seconds
@AssistedInject @AssistedInject
class CallScreenPresenter( class CallScreenPresenter(
@Assisted private val callType: CallType, @Assisted private val callData: CallData,
@Assisted private val navigator: CallScreenNavigator, @Assisted private val navigator: CallScreenNavigator,
private val callWidgetProvider: CallWidgetProvider, private val callWidgetProvider: CallWidgetProvider,
userAgentProvider: UserAgentProvider, userAgentProvider: UserAgentProvider,
@ -69,10 +69,9 @@ class CallScreenPresenter(
) : Presenter<CallScreenState> { ) : Presenter<CallScreenState> {
@AssistedFactory @AssistedFactory
interface Factory { interface Factory {
fun create(callType: CallType, navigator: CallScreenNavigator): CallScreenPresenter fun create(callData: CallData, navigator: CallScreenNavigator): CallScreenPresenter
} }
private val isInWidgetMode = callType is CallType.RoomCall
private val userAgent = userAgentProvider.provide() private val userAgent = userAgentProvider.provide()
@Composable @Composable
@ -90,9 +89,9 @@ class CallScreenPresenter(
DisposableEffect(Unit) { DisposableEffect(Unit) {
coroutineScope.launch { coroutineScope.launch {
// Sets the call as joined // Sets the call as joined
activeCallManager.joinedCall(callType) activeCallManager.joinedCall(callData)
fetchRoomCallUrl( fetchRoomCallUrl(
inputs = callType, callData = callData,
urlState = urlState, urlState = urlState,
callWidgetDriver = callWidgetDriver, callWidgetDriver = callWidgetDriver,
languageTag = languageTag, languageTag = languageTag,
@ -100,19 +99,10 @@ class CallScreenPresenter(
) )
} }
onDispose { onDispose {
appCoroutineScope.launch { activeCallManager.hangUpCall(callType) } appCoroutineScope.launch { activeCallManager.hangUpCall(callData) }
} }
} }
screenTracker.TrackScreen(screen = MobileScreen.ScreenName.RoomCall)
when (callType) {
is CallType.ExternalUrl -> {
// No analytics yet for external calls
}
is CallType.RoomCall -> {
screenTracker.TrackScreen(screen = MobileScreen.ScreenName.RoomCall)
}
}
HandleMatrixClientSyncState() HandleMatrixClientSyncState()
callWidgetDriver.value?.let { driver -> callWidgetDriver.value?.let { driver ->
@ -149,25 +139,22 @@ class CallScreenPresenter(
.launchIn(this) .launchIn(this)
} }
if (callType is CallType.RoomCall) { LaunchedEffect(Unit) {
// Note: For external calls isWidgetLoaded will always be false // Wait for the call to be joined, if it takes too long, we display an error
LaunchedEffect(Unit) { delay(10.seconds)
// Wait for the call to be joined, if it takes too long, we display an error
delay(10.seconds)
if (!isWidgetLoaded) { if (!isWidgetLoaded) {
Timber.w("The call took too long to load. Displaying an error before exiting.") Timber.w("The call took too long to load. Displaying an error before exiting.")
// This will display a simple 'Sorry, an error occurred' dialog and force the user to exit the call // This will display a simple 'Sorry, an error occurred' dialog and force the user to exit the call
webViewError = "" webViewError = ""
}
} }
} }
} }
fun handleEvent(event: CallScreenEvents) { fun handleEvent(event: CallScreenEvent) {
when (event) { when (event) {
is CallScreenEvents.Hangup -> { is CallScreenEvent.Hangup -> {
val widgetId = callWidgetDriver.value?.id val widgetId = callWidgetDriver.value?.id
val interceptor = messageInterceptor.value val interceptor = messageInterceptor.value
if (widgetId != null && interceptor != null && isWidgetLoaded) { if (widgetId != null && interceptor != null && isWidgetLoaded) {
@ -187,10 +174,10 @@ class CallScreenPresenter(
} }
} }
} }
is CallScreenEvents.SetupMessageChannels -> { is CallScreenEvent.SetupMessageChannels -> {
messageInterceptor.value = event.widgetMessageInterceptor messageInterceptor.value = event.widgetMessageInterceptor
} }
is CallScreenEvents.OnWebViewError -> { is CallScreenEvent.OnWebViewError -> {
if (!ignoreWebViewError) { if (!ignoreWebViewError) {
webViewError = event.description.orEmpty() webViewError = event.description.orEmpty()
} }
@ -204,37 +191,29 @@ class CallScreenPresenter(
webViewError = webViewError, webViewError = webViewError,
userAgent = userAgent, userAgent = userAgent,
isCallActive = isWidgetLoaded, isCallActive = isWidgetLoaded,
isInWidgetMode = isInWidgetMode,
eventSink = ::handleEvent, eventSink = ::handleEvent,
) )
} }
private suspend fun fetchRoomCallUrl( private suspend fun fetchRoomCallUrl(
inputs: CallType, callData: CallData,
urlState: MutableState<AsyncData<String>>, urlState: MutableState<AsyncData<String>>,
callWidgetDriver: MutableState<MatrixWidgetDriver?>, callWidgetDriver: MutableState<MatrixWidgetDriver?>,
languageTag: String?, languageTag: String?,
theme: String?, theme: String?,
) { ) {
urlState.runCatchingUpdatingState { urlState.runCatchingUpdatingState {
when (inputs) { val result = callWidgetProvider.getWidget(
is CallType.ExternalUrl -> { sessionId = callData.sessionId,
inputs.url roomId = callData.roomId,
} clientId = UUID.randomUUID().toString(),
is CallType.RoomCall -> { isAudioCall = callData.isAudioCall,
val result = callWidgetProvider.getWidget( languageTag = languageTag,
sessionId = inputs.sessionId, theme = theme,
roomId = inputs.roomId, ).getOrThrow()
clientId = UUID.randomUUID().toString(), callWidgetDriver.value = result.driver
isAudioCall = inputs.isAudioCall, Timber.d("Call widget driver initialized for sessionId: ${callData.sessionId}, roomId: ${callData.roomId}")
languageTag = languageTag, result.url
theme = theme,
).getOrThrow()
callWidgetDriver.value = result.driver
Timber.d("Call widget driver initialized for sessionId: ${inputs.sessionId}, roomId: ${inputs.roomId}")
result.url
}
}
} }
} }
@ -242,12 +221,11 @@ class CallScreenPresenter(
private fun HandleMatrixClientSyncState() { private fun HandleMatrixClientSyncState() {
val coroutineScope = rememberCoroutineScope() val coroutineScope = rememberCoroutineScope()
DisposableEffect(Unit) { DisposableEffect(Unit) {
val roomCallType = callType as? CallType.RoomCall ?: return@DisposableEffect onDispose {} val client = matrixClientsProvider.getOrNull(callData.sessionId) ?: return@DisposableEffect onDispose {
val client = matrixClientsProvider.getOrNull(roomCallType.sessionId) ?: return@DisposableEffect onDispose { Timber.w("No MatrixClient found for sessionId, can't send call notification: ${callData.sessionId}")
Timber.w("No MatrixClient found for sessionId, can't send call notification: ${roomCallType.sessionId}")
} }
coroutineScope.launch { coroutineScope.launch {
Timber.d("Observing sync state in-call for sessionId: ${roomCallType.sessionId}") Timber.d("Observing sync state in-call for sessionId: ${callData.sessionId}")
client.syncService.syncState client.syncService.syncState
.collect { state -> .collect { state ->
if (state != SyncState.Running) { if (state != SyncState.Running) {
@ -256,7 +234,7 @@ class CallScreenPresenter(
} }
} }
onDispose { onDispose {
Timber.d("Stopped observing sync state in-call for sessionId: ${roomCallType.sessionId}") Timber.d("Stopped observing sync state in-call for sessionId: ${callData.sessionId}")
// Make sure we mark the call as ended in the app state // Make sure we mark the call as ended in the app state
appForegroundStateService.updateIsInCallState(false) appForegroundStateService.updateIsInCallState(false)
} }

View file

@ -15,6 +15,5 @@ data class CallScreenState(
val webViewError: String?, val webViewError: String?,
val userAgent: String, val userAgent: String,
val isCallActive: Boolean, val isCallActive: Boolean,
val isInWidgetMode: Boolean, val eventSink: (CallScreenEvent) -> Unit,
val eventSink: (CallScreenEvents) -> Unit,
) )

View file

@ -26,15 +26,13 @@ internal fun aCallScreenState(
webViewError: String? = null, webViewError: String? = null,
userAgent: String = "", userAgent: String = "",
isCallActive: Boolean = true, isCallActive: Boolean = true,
isInWidgetMode: Boolean = false, eventSink: (CallScreenEvent) -> Unit = {},
eventSink: (CallScreenEvents) -> Unit = {},
): CallScreenState { ): CallScreenState {
return CallScreenState( return CallScreenState(
urlState = urlState, urlState = urlState,
webViewError = webViewError, webViewError = webViewError,
userAgent = userAgent, userAgent = userAgent,
isCallActive = isCallActive, isCallActive = isCallActive,
isInWidgetMode = isInWidgetMode,
eventSink = eventSink, eventSink = eventSink,
) )
} }

View file

@ -33,7 +33,7 @@ import androidx.compose.ui.res.stringResource
import androidx.compose.ui.tooling.preview.PreviewParameter import androidx.compose.ui.tooling.preview.PreviewParameter
import androidx.compose.ui.viewinterop.AndroidView import androidx.compose.ui.viewinterop.AndroidView
import io.element.android.features.call.impl.R import io.element.android.features.call.impl.R
import io.element.android.features.call.impl.pip.PictureInPictureEvents import io.element.android.features.call.impl.pip.PictureInPictureEvent
import io.element.android.features.call.impl.pip.PictureInPictureState import io.element.android.features.call.impl.pip.PictureInPictureState
import io.element.android.features.call.impl.pip.aPictureInPictureState import io.element.android.features.call.impl.pip.aPictureInPictureState
import io.element.android.features.call.impl.utils.InvalidAudioDeviceReason import io.element.android.features.call.impl.utils.InvalidAudioDeviceReason
@ -64,11 +64,15 @@ internal fun CallScreenView(
requestPermissions: (Array<String>, RequestPermissionCallback) -> Unit, requestPermissions: (Array<String>, RequestPermissionCallback) -> Unit,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
) { ) {
fun handleBack() { var callWebView by remember { mutableStateOf<WebView?>(null) }
if (pipState.supportPip) {
pipState.eventSink.invoke(PictureInPictureEvents.EnterPictureInPicture) fun handleBack(fromNative: Boolean = false) {
} else { when (CallScreenBackPressPolicy.resolve(supportPip = pipState.supportPip, hasWebView = callWebView != null, fromNative)) {
state.eventSink(CallScreenEvents.Hangup) CallScreenBackPressAction.EnterPictureInPicture ->
pipState.eventSink(PictureInPictureEvent.EnterPictureInPicture)
CallScreenBackPressAction.DispatchEscapeToWebView ->
callWebView?.dispatchEscKeyEvent()
null -> Timber.d("Back press with unsupported pip is a no-op")
} }
} }
@ -76,7 +80,7 @@ internal fun CallScreenView(
modifier = modifier, modifier = modifier,
) { padding -> ) { padding ->
BackHandler { BackHandler {
handleBack() handleBack(fromNative = true)
} }
if (state.webViewError != null) { if (state.webViewError != null) {
ErrorDialog( ErrorDialog(
@ -84,7 +88,7 @@ internal fun CallScreenView(
append(stringResource(CommonStrings.error_unknown)) append(stringResource(CommonStrings.error_unknown))
state.webViewError.takeIf { it.isNotEmpty() }?.let { append("\n\n").append(it) } state.webViewError.takeIf { it.isNotEmpty() }?.let { append("\n\n").append(it) }
}, },
onSubmit = { state.eventSink(CallScreenEvents.Hangup) }, onSubmit = { state.eventSink(CallScreenEvent.Hangup) },
) )
} else { } else {
var webViewAudioManager by remember { mutableStateOf<WebViewAudioManager?>(null) } var webViewAudioManager by remember { mutableStateOf<WebViewAudioManager?>(null) }
@ -111,6 +115,7 @@ internal fun CallScreenView(
}, },
onConsoleMessage = onConsoleMessage, onConsoleMessage = onConsoleMessage,
onCreateWebView = { webView -> onCreateWebView = { webView ->
callWebView = webView
webView.addBackHandler(onBackPressed = ::handleBack) webView.addBackHandler(onBackPressed = ::handleBack)
val interceptor = WebViewWidgetMessageInterceptor( val interceptor = WebViewWidgetMessageInterceptor(
webView = webView, webView = webView,
@ -123,18 +128,19 @@ internal fun CallScreenView(
Timber.d("Can't start in-call audio mode since the app is already in it.") Timber.d("Can't start in-call audio mode since the app is already in it.")
} }
}, },
onError = { state.eventSink(CallScreenEvents.OnWebViewError(it)) }, onError = { state.eventSink(CallScreenEvent.OnWebViewError(it)) },
) )
webViewAudioManager = WebViewAudioManager( webViewAudioManager = WebViewAudioManager(
webView = webView, webView = webView,
coroutineScope = coroutineScope, coroutineScope = coroutineScope,
onInvalidAudioDeviceAdded = { invalidAudioDeviceReason = it }, onInvalidAudioDeviceAdded = { invalidAudioDeviceReason = it },
) )
state.eventSink(CallScreenEvents.SetupMessageChannels(interceptor)) state.eventSink(CallScreenEvent.SetupMessageChannels(interceptor))
val pipController = WebViewPipController(webView) val pipController = WebViewPipController(webView)
pipState.eventSink(PictureInPictureEvents.SetPipController(pipController)) pipState.eventSink(PictureInPictureEvent.SetPipController(pipController))
}, },
onDestroyWebView = { onDestroyWebView = {
callWebView = null
// Reset audio mode // Reset audio mode
webViewAudioManager?.onCallStopped() webViewAudioManager?.onCallStopped()
} }
@ -143,13 +149,15 @@ internal fun CallScreenView(
AsyncData.Uninitialized, AsyncData.Uninitialized,
is AsyncData.Loading -> is AsyncData.Loading ->
ProgressDialog(text = stringResource(id = CommonStrings.common_please_wait)) ProgressDialog(text = stringResource(id = CommonStrings.common_please_wait))
is AsyncData.Failure -> { is AsyncData.Failure -> {
Timber.e(state.urlState.error, "WebView failed to load URL: ${state.urlState.error.message}") Timber.e(state.urlState.error, "WebView failed to load URL: ${state.urlState.error.message}")
ErrorDialog( ErrorDialog(
content = state.urlState.error.message.orEmpty(), content = state.urlState.error.message.orEmpty(),
onSubmit = { state.eventSink(CallScreenEvents.Hangup) }, onSubmit = { state.eventSink(CallScreenEvent.Hangup) },
) )
} }
is AsyncData.Success -> Unit is AsyncData.Success -> Unit
} }
} }
@ -248,15 +256,18 @@ private fun WebView.setup(
private fun WebView.addBackHandler(onBackPressed: () -> Unit) { private fun WebView.addBackHandler(onBackPressed: () -> Unit) {
addJavascriptInterface( addJavascriptInterface(
object { JavascriptBackHandler {
@Suppress("unused") onBackPressed()
@JavascriptInterface
fun onBackPressed() = onBackPressed()
}, },
"backHandler" "backHandler"
) )
} }
private fun WebView.dispatchEscKeyEvent() {
dispatchKeyEvent(android.view.KeyEvent(android.view.KeyEvent.ACTION_DOWN, android.view.KeyEvent.KEYCODE_ESCAPE))
dispatchKeyEvent(android.view.KeyEvent(android.view.KeyEvent.ACTION_UP, android.view.KeyEvent.KEYCODE_ESCAPE))
}
@PreviewsDayNight @PreviewsDayNight
@Composable @Composable
internal fun CallScreenViewPreview( internal fun CallScreenViewPreview(
@ -275,3 +286,8 @@ internal fun CallScreenViewPreview(
internal fun InvalidAudioDeviceDialogPreview() = ElementPreview { internal fun InvalidAudioDeviceDialogPreview() = ElementPreview {
InvalidAudioDeviceDialog(invalidAudioDeviceReason = InvalidAudioDeviceReason.BT_AUDIO_DEVICE_DISABLED) {} InvalidAudioDeviceDialog(invalidAudioDeviceReason = InvalidAudioDeviceReason.BT_AUDIO_DEVICE_DISABLED) {}
} }
internal fun interface JavascriptBackHandler {
@JavascriptInterface
fun onBackPressed()
}

View file

@ -1,19 +0,0 @@
/*
* Copyright (c) 2025 Element Creations Ltd.
* Copyright 2025 New Vector Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
* Please see LICENSE files in the repository root for full details.
*/
package io.element.android.features.call.impl.ui
import io.element.android.features.call.api.CallType
import io.element.android.libraries.matrix.api.core.SessionId
fun CallType.getSessionId(): SessionId? {
return when (this) {
is CallType.ExternalUrl -> null
is CallType.RoomCall -> sessionId
}
}

View file

@ -35,16 +35,14 @@ import androidx.core.util.Consumer
import androidx.lifecycle.Lifecycle import androidx.lifecycle.Lifecycle
import dev.zacsweers.metro.Inject import dev.zacsweers.metro.Inject
import io.element.android.compound.colors.SemanticColorsLightDark import io.element.android.compound.colors.SemanticColorsLightDark
import io.element.android.features.call.api.CallType import io.element.android.features.call.api.CallData
import io.element.android.features.call.api.CallType.ExternalUrl
import io.element.android.features.call.impl.DefaultElementCallEntryPoint import io.element.android.features.call.impl.DefaultElementCallEntryPoint
import io.element.android.features.call.impl.di.CallBindings import io.element.android.features.call.impl.di.CallBindings
import io.element.android.features.call.impl.pip.PictureInPictureEvents import io.element.android.features.call.impl.pip.PictureInPictureEvent
import io.element.android.features.call.impl.pip.PictureInPicturePresenter import io.element.android.features.call.impl.pip.PictureInPicturePresenter
import io.element.android.features.call.impl.pip.PictureInPictureState import io.element.android.features.call.impl.pip.PictureInPictureState
import io.element.android.features.call.impl.pip.PipView import io.element.android.features.call.impl.pip.PipView
import io.element.android.features.call.impl.services.CallForegroundService import io.element.android.features.call.impl.services.CallForegroundService
import io.element.android.features.call.impl.utils.CallIntentDataParser
import io.element.android.features.enterprise.api.EnterpriseService import io.element.android.features.enterprise.api.EnterpriseService
import io.element.android.libraries.androidutils.browser.ConsoleMessageLogger import io.element.android.libraries.androidutils.browser.ConsoleMessageLogger
import io.element.android.libraries.architecture.Presenter import io.element.android.libraries.architecture.Presenter
@ -64,7 +62,6 @@ class ElementCallActivity :
AppCompatActivity(), AppCompatActivity(),
CallScreenNavigator, CallScreenNavigator,
PipView { PipView {
@Inject lateinit var callIntentDataParser: CallIntentDataParser
@Inject lateinit var presenterFactory: CallScreenPresenter.Factory @Inject lateinit var presenterFactory: CallScreenPresenter.Factory
@Inject lateinit var appPreferencesStore: AppPreferencesStore @Inject lateinit var appPreferencesStore: AppPreferencesStore
@Inject lateinit var featureFlagService: FeatureFlagService @Inject lateinit var featureFlagService: FeatureFlagService
@ -80,9 +77,9 @@ class ElementCallActivity :
private val requestPermissionsLauncher = registerPermissionResultLauncher() private val requestPermissionsLauncher = registerPermissionResultLauncher()
private val webViewTarget = mutableStateOf<CallType?>(null) private val webViewTarget = mutableStateOf<CallData?>(null)
private var eventSink: ((CallScreenEvents) -> Unit)? = null private var eventSink: ((CallScreenEvent) -> Unit)? = null
override fun onCreate(savedInstanceState: Bundle?) { override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState) super.onCreate(savedInstanceState)
@ -98,7 +95,7 @@ class ElementCallActivity :
window.addFlags(WindowManager.LayoutParams.FLAG_SHOW_WHEN_LOCKED) window.addFlags(WindowManager.LayoutParams.FLAG_SHOW_WHEN_LOCKED)
} }
setCallType(intent) setCallData(intent)
// If presenter is not created at this point, it means we have no call to display, the Activity is finishing, so return early // If presenter is not created at this point, it means we have no call to display, the Activity is finishing, so return early
if (!::presenter.isInitialized) { if (!::presenter.isInitialized) {
return return
@ -111,8 +108,8 @@ class ElementCallActivity :
setContent { setContent {
val pipState = pictureInPicturePresenter.present() val pipState = pictureInPicturePresenter.present()
ListenToAndroidEvents(pipState) ListenToAndroidEvents(pipState)
val colors by remember(webViewTarget.value?.getSessionId()) { val colors by remember(webViewTarget.value?.sessionId) {
enterpriseService.semanticColorsFlow(sessionId = webViewTarget.value?.getSessionId()) enterpriseService.semanticColorsFlow(sessionId = webViewTarget.value?.sessionId)
}.collectAsState(SemanticColorsLightDark.default) }.collectAsState(SemanticColorsLightDark.default)
ElementThemeApp( ElementThemeApp(
appPreferencesStore = appPreferencesStore, appPreferencesStore = appPreferencesStore,
@ -123,9 +120,8 @@ class ElementCallActivity :
) { ) {
val state = presenter.present() val state = presenter.present()
eventSink = state.eventSink eventSink = state.eventSink
LaunchedEffect(state.isCallActive, state.isInWidgetMode) { LaunchedEffect(state.isCallActive) {
// Note when not in WidgetMode, isCallActive will never be true, so consider the call is active if (state.isCallActive) {
if (state.isCallActive || !state.isInWidgetMode) {
setCallIsActive() setCallIsActive()
} }
} }
@ -163,7 +159,7 @@ class ElementCallActivity :
if (requestPermissionCallback != null) { if (requestPermissionCallback != null) {
Timber.tag(loggerTag.value).w("Ignoring onUserLeaveHint event because user is asked to grant permissions") Timber.tag(loggerTag.value).w("Ignoring onUserLeaveHint event because user is asked to grant permissions")
} else { } else {
pipEventSink(PictureInPictureEvents.EnterPictureInPicture) pipEventSink(PictureInPictureEvent.EnterPictureInPicture)
} }
} }
addOnUserLeaveHintListener(listener) addOnUserLeaveHintListener(listener)
@ -173,10 +169,10 @@ class ElementCallActivity :
} }
DisposableEffect(Unit) { DisposableEffect(Unit) {
val onPictureInPictureModeChangedListener = Consumer { _: PictureInPictureModeChangedInfo -> val onPictureInPictureModeChangedListener = Consumer { _: PictureInPictureModeChangedInfo ->
pipEventSink(PictureInPictureEvents.OnPictureInPictureModeChanged(isInPictureInPictureMode)) pipEventSink(PictureInPictureEvent.OnPictureInPictureModeChanged(isInPictureInPictureMode))
if (!isInPictureInPictureMode && !lifecycle.currentState.isAtLeast(Lifecycle.State.STARTED)) { if (!isInPictureInPictureMode && !lifecycle.currentState.isAtLeast(Lifecycle.State.STARTED)) {
Timber.tag(loggerTag.value).d("Exiting PiP mode: Hangup the call") Timber.tag(loggerTag.value).d("Exiting PiP mode: Hangup the call")
eventSink?.invoke(CallScreenEvents.Hangup) eventSink?.invoke(CallScreenEvent.Hangup)
} }
} }
addOnPictureInPictureModeChangedListener(onPictureInPictureModeChangedListener) addOnPictureInPictureModeChangedListener(onPictureInPictureModeChangedListener)
@ -188,7 +184,7 @@ class ElementCallActivity :
override fun onNewIntent(intent: Intent) { override fun onNewIntent(intent: Intent) {
super.onNewIntent(intent) super.onNewIntent(intent)
setCallType(intent) setCallData(intent)
} }
override fun onDestroy() { override fun onDestroy() {
@ -207,25 +203,24 @@ class ElementCallActivity :
finish() finish()
} }
private fun setCallType(intent: Intent?) { private fun setCallData(intent: Intent?) {
val callType = intent?.let { val callData = intent?.let {
IntentCompat.getParcelableExtra(intent, DefaultElementCallEntryPoint.EXTRA_CALL_TYPE, CallType::class.java) IntentCompat.getParcelableExtra(intent, DefaultElementCallEntryPoint.EXTRA_CALL_TYPE, CallData::class.java)
?: intent.dataString?.let(::parseUrl)?.let(::ExternalUrl)
} }
val currentCallType = webViewTarget.value val currentCallData = webViewTarget.value
if (currentCallType == null) { if (currentCallData == null) {
if (callType == null) { if (callData == null) {
Timber.tag(loggerTag.value).d("Re-opened the activity but we have no url to load or a cached one, finish the activity") Timber.tag(loggerTag.value).d("Re-opened the activity but we have no url to load or a cached one, finish the activity")
finish() finish()
} else { } else {
Timber.tag(loggerTag.value).d("Set the call type and create the presenter") Timber.tag(loggerTag.value).d("Set the call type and create the presenter")
webViewTarget.value = callType webViewTarget.value = callData
presenter = presenterFactory.create(callType, this) presenter = presenterFactory.create(callData, this)
} }
} else { } else {
if (callType == null) { if (callData == null) {
Timber.tag(loggerTag.value).d("Coming back from notification, do nothing") Timber.tag(loggerTag.value).d("Coming back from notification, do nothing")
} else if (callType != currentCallType) { } else if (callData != currentCallData) {
Timber.tag(loggerTag.value).d("User starts another call, restart the Activity") Timber.tag(loggerTag.value).d("User starts another call, restart the Activity")
setIntent(intent) setIntent(intent)
recreate() recreate()
@ -236,8 +231,6 @@ class ElementCallActivity :
} }
} }
private fun parseUrl(url: String?): String? = callIntentDataParser.parse(url)
private fun registerPermissionResultLauncher(): ActivityResultLauncher<Array<String>> { private fun registerPermissionResultLauncher(): ActivityResultLauncher<Array<String>> {
return registerForActivityResult( return registerForActivityResult(
ActivityResultContracts.RequestMultiplePermissions() ActivityResultContracts.RequestMultiplePermissions()
@ -287,7 +280,7 @@ class ElementCallActivity :
} }
override fun hangUp() { override fun hangUp() {
eventSink?.invoke(CallScreenEvents.Hangup) eventSink?.invoke(CallScreenEvent.Hangup)
} }
} }

View file

@ -19,7 +19,7 @@ import androidx.core.content.IntentCompat
import androidx.lifecycle.lifecycleScope import androidx.lifecycle.lifecycleScope
import dev.zacsweers.metro.Inject import dev.zacsweers.metro.Inject
import io.element.android.compound.colors.SemanticColorsLightDark import io.element.android.compound.colors.SemanticColorsLightDark
import io.element.android.features.call.api.CallType import io.element.android.features.call.api.CallData
import io.element.android.features.call.api.ElementCallEntryPoint import io.element.android.features.call.api.ElementCallEntryPoint
import io.element.android.features.call.impl.di.CallBindings import io.element.android.features.call.impl.di.CallBindings
import io.element.android.features.call.impl.notifications.CallNotificationData import io.element.android.features.call.impl.notifications.CallNotificationData
@ -118,10 +118,10 @@ class IncomingCallActivity : AppCompatActivity() {
private fun onAnswer(notificationData: CallNotificationData) { private fun onAnswer(notificationData: CallNotificationData) {
elementCallEntryPoint.startCall( elementCallEntryPoint.startCall(
CallType.RoomCall( CallData(
notificationData.sessionId, sessionId = notificationData.sessionId,
notificationData.roomId, roomId = notificationData.roomId,
isAudioCall = notificationData.audioOnly isAudioCall = notificationData.audioOnly,
) )
) )
} }
@ -129,7 +129,7 @@ class IncomingCallActivity : AppCompatActivity() {
private fun onCancel() { private fun onCancel() {
val activeCall = activeCallManager.activeCall.value ?: return val activeCall = activeCallManager.activeCall.value ?: return
appCoroutineScope.launch { appCoroutineScope.launch {
activeCallManager.hangUpCall(callType = activeCall.callType) activeCallManager.hangUpCall(callData = activeCall.callData)
} }
} }
} }

View file

@ -20,7 +20,7 @@ import dev.zacsweers.metro.AppScope
import dev.zacsweers.metro.ContributesBinding import dev.zacsweers.metro.ContributesBinding
import dev.zacsweers.metro.SingleIn import dev.zacsweers.metro.SingleIn
import io.element.android.appconfig.ElementCallConfig import io.element.android.appconfig.ElementCallConfig
import io.element.android.features.call.api.CallType import io.element.android.features.call.api.CallData
import io.element.android.features.call.api.CurrentCall import io.element.android.features.call.api.CurrentCall
import io.element.android.features.call.impl.notifications.CallNotificationData import io.element.android.features.call.impl.notifications.CallNotificationData
import io.element.android.features.call.impl.notifications.RingingCallNotificationCreator import io.element.android.features.call.impl.notifications.RingingCallNotificationCreator
@ -73,20 +73,20 @@ interface ActiveCallManager {
/** /**
* Called to hang up the active call. It will hang up the call and remove any existing UI and the active call. * Called to hang up the active call. It will hang up the call and remove any existing UI and the active call.
* @param callType The type of call that the user hangs up, either an external url one or a room one. * @param callData The data about the call.
* @param notificationData The data for the incoming call notification. * @param notificationData The data for the incoming call notification.
*/ */
suspend fun hangUpCall( suspend fun hangUpCall(
callType: CallType, callData: CallData,
notificationData: CallNotificationData? = null, notificationData: CallNotificationData? = null,
) )
/** /**
* Called after the user joined a call. It will remove any existing UI and set the call state as [CallState.InCall]. * Called after the user joined a call. It will remove any existing UI and set the call state as [CallState.InCall].
* *
* @param callType The type of call that the user joined, either an external url one or a room one. * @param callData The data about the call.
*/ */
suspend fun joinedCall(callType: CallType) suspend fun joinedCall(callData: CallData)
} }
@SingleIn(AppScope::class) @SingleIn(AppScope::class)
@ -143,7 +143,7 @@ class DefaultActiveCallManager(
return return
} }
activeCall.value = ActiveCall( activeCall.value = ActiveCall(
callType = CallType.RoomCall( callData = CallData(
sessionId = notificationData.sessionId, sessionId = notificationData.sessionId,
roomId = notificationData.roomId, roomId = notificationData.roomId,
isAudioCall = notificationData.audioOnly, isAudioCall = notificationData.audioOnly,
@ -198,17 +198,17 @@ class DefaultActiveCallManager(
} }
override suspend fun hangUpCall( override suspend fun hangUpCall(
callType: CallType, callData: CallData,
notificationData: CallNotificationData?, notificationData: CallNotificationData?,
) = mutex.withLock { ) = mutex.withLock {
Timber.tag(tag).d("Hang up call: $callType") Timber.tag(tag).d("Hang up call: $callData")
cancelIncomingCallNotification() cancelIncomingCallNotification()
val currentActiveCall = activeCall.value ?: run { val currentActiveCall = activeCall.value ?: run {
// activeCall.value can be null if the application has been killed while the call was ringing // activeCall.value can be null if the application has been killed while the call was ringing
// Build a currentActiveCall with the provided parameters. // Build a currentActiveCall with the provided parameters.
notificationData?.let { notificationData?.let {
ActiveCall( ActiveCall(
callType = callType, callData = callData,
callState = CallState.Ringing( callState = CallState.Ringing(
notificationData = notificationData, notificationData = notificationData,
) )
@ -219,8 +219,8 @@ class DefaultActiveCallManager(
return@withLock return@withLock
} }
if (currentActiveCall.callType != callType) { if (currentActiveCall.callData != callData) {
Timber.tag(tag).w("Call type $callType does not match the active call type, ignoring") Timber.tag(tag).w("Call type $callData does not match the active call type, ignoring")
return@withLock return@withLock
} }
if (currentActiveCall.callState is CallState.Ringing) { if (currentActiveCall.callState is CallState.Ringing) {
@ -244,8 +244,8 @@ class DefaultActiveCallManager(
activeCall.value = null activeCall.value = null
} }
override suspend fun joinedCall(callType: CallType) = mutex.withLock { override suspend fun joinedCall(callData: CallData) = mutex.withLock {
Timber.tag(tag).d("Joined call: $callType") Timber.tag(tag).d("Joined call: $callData")
cancelIncomingCallNotification() cancelIncomingCallNotification()
if (activeWakeLock?.isHeld == true) { if (activeWakeLock?.isHeld == true) {
Timber.tag(tag).d("Releasing partial wakelock after joining call") Timber.tag(tag).d("Releasing partial wakelock after joining call")
@ -254,7 +254,7 @@ class DefaultActiveCallManager(
timedOutCallJob?.cancel() timedOutCallJob?.cancel()
activeCall.value = ActiveCall( activeCall.value = ActiveCall(
callType = callType, callData = callData,
callState = CallState.InCall, callState = CallState.InCall,
) )
} }
@ -307,15 +307,15 @@ class DefaultActiveCallManager(
private fun observeRingingCall() { private fun observeRingingCall() {
activeCall activeCall
.filterNotNull() .filterNotNull()
.filter { it.callState is CallState.Ringing && it.callType is CallType.RoomCall } .filter { it.callState is CallState.Ringing }
.flatMapLatest { activeCall -> .flatMapLatest { activeCall ->
val callType = activeCall.callType as CallType.RoomCall val callData = activeCall.callData
val ringingInfo = activeCall.callState as CallState.Ringing val ringingInfo = activeCall.callState as CallState.Ringing
val client = matrixClientProvider.getOrRestore(callType.sessionId).getOrNull() ?: run { val client = matrixClientProvider.getOrRestore(callData.sessionId).getOrNull() ?: run {
Timber.tag(tag).d("Couldn't find session for incoming call: $activeCall") Timber.tag(tag).d("Couldn't find session for incoming call: $activeCall")
return@flatMapLatest flowOf() return@flatMapLatest flowOf()
} }
val room = client.getRoom(callType.roomId) ?: run { val room = client.getRoom(callData.roomId) ?: run {
Timber.tag(tag).d("Couldn't find room for incoming call: $activeCall") Timber.tag(tag).d("Couldn't find room for incoming call: $activeCall")
return@flatMapLatest flowOf() return@flatMapLatest flowOf()
} }
@ -346,17 +346,17 @@ class DefaultActiveCallManager(
// has joined the call from another session. // has joined the call from another session.
activeCall activeCall
.filterNotNull() .filterNotNull()
.filter { it.callState is CallState.Ringing && it.callType is CallType.RoomCall } .filter { it.callState is CallState.Ringing }
.flatMapLatest { activeCall -> .flatMapLatest { activeCall ->
val callType = activeCall.callType as CallType.RoomCall val callData = activeCall.callData
// Get a flow of updated `hasRoomCall` and `activeRoomCallParticipants` values for the room // Get a flow of updated `hasRoomCall` and `activeRoomCallParticipants` values for the room
val room = matrixClientProvider.getOrRestore(callType.sessionId).getOrNull()?.getRoom(callType.roomId) ?: run { val room = matrixClientProvider.getOrRestore(callData.sessionId).getOrNull()?.getRoom(callData.roomId) ?: run {
Timber.tag(tag).d("Couldn't find room for incoming call: $activeCall") Timber.tag(tag).d("Couldn't find room for incoming call: $activeCall")
return@flatMapLatest flowOf() return@flatMapLatest flowOf()
} }
room.roomInfoFlow.map { room.roomInfoFlow.map {
Timber.tag(tag).d("Has room call status changed for ringing call: ${it.hasRoomCall}") Timber.tag(tag).d("Has room call status changed for ringing call: ${it.hasRoomCall}")
it.hasRoomCall to (callType.sessionId in it.activeRoomCallParticipants) it.hasRoomCall to (callData.sessionId in it.activeRoomCallParticipants)
} }
} }
// We only want to check if the room active call status changes // We only want to check if the room active call status changes
@ -388,10 +388,7 @@ class DefaultActiveCallManager(
// Nothing to do // Nothing to do
} }
is CallState.InCall -> { is CallState.InCall -> {
when (val callType = value.callType) { defaultCurrentCallService.onCallStarted(CurrentCall.RoomCall(value.callData.roomId))
is CallType.ExternalUrl -> defaultCurrentCallService.onCallStarted(CurrentCall.ExternalUrl(callType.url))
is CallType.RoomCall -> defaultCurrentCallService.onCallStarted(CurrentCall.RoomCall(callType.roomId))
}
} }
} }
} }
@ -404,7 +401,7 @@ class DefaultActiveCallManager(
* Represents an active call. * Represents an active call.
*/ */
data class ActiveCall( data class ActiveCall(
val callType: CallType, val callData: CallData,
val callState: CallState, val callState: CallState,
) )

View file

@ -1,98 +0,0 @@
/*
* Copyright (c) 2025 Element Creations Ltd.
* Copyright 2023-2025 New Vector Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
* Please see LICENSE files in the repository root for full details.
*/
package io.element.android.features.call.impl.utils
import android.net.Uri
import androidx.core.net.toUri
import dev.zacsweers.metro.Inject
@Inject
class CallIntentDataParser {
private val validHttpSchemes = sequenceOf("https")
private val knownHosts = sequenceOf(
"call.element.io",
)
fun parse(data: String?): String? {
val parsedUrl = data?.toUri() ?: return null
val scheme = parsedUrl.scheme
return when {
scheme in validHttpSchemes -> parsedUrl
scheme == "element" && parsedUrl.host == "call" -> {
parsedUrl.getUrlParameter()
}
scheme == "io.element.call" && parsedUrl.host == null -> {
parsedUrl.getUrlParameter()
}
// This should never be possible, but we still need to take into account the possibility
else -> null
}
?.takeIf { it.host in knownHosts }
?.withCustomParameters()
}
private fun Uri.getUrlParameter(): Uri? {
return getQueryParameter("url")
?.let { urlParameter ->
urlParameter.toUri().takeIf { uri ->
uri.scheme in validHttpSchemes && !uri.host.isNullOrBlank()
}
}
}
}
/**
* Ensure the uri has the following parameters and value in the fragment:
* - appPrompt=false
* - confineToRoom=true
* to ensure that the rendering will bo correct on the embedded Webview.
*/
private fun Uri.withCustomParameters(): String {
val builder = buildUpon()
// Remove the existing query parameters
builder.clearQuery()
queryParameterNames.forEach {
if (it == APP_PROMPT_PARAMETER || it == CONFINE_TO_ROOM_PARAMETER) return@forEach
builder.appendQueryParameter(it, getQueryParameter(it))
}
// Remove the existing fragment parameters, and build the new fragment
val currentFragment = fragment ?: ""
// Reset the current fragment
builder.fragment("")
val queryFragmentPosition = currentFragment.lastIndexOf("?")
val newFragment = if (queryFragmentPosition == -1) {
// No existing query, build it.
"$currentFragment?$APP_PROMPT_PARAMETER=false&$CONFINE_TO_ROOM_PARAMETER=true"
} else {
buildString {
append(currentFragment.substring(0, queryFragmentPosition + 1))
val queryFragment = currentFragment.substring(queryFragmentPosition + 1)
// Replace the existing parameters
val newQueryFragment = queryFragment
.replace("$APP_PROMPT_PARAMETER=true", "$APP_PROMPT_PARAMETER=false")
.replace("$CONFINE_TO_ROOM_PARAMETER=false", "$CONFINE_TO_ROOM_PARAMETER=true")
append(newQueryFragment)
// Ensure the parameters are there
if (!newQueryFragment.contains("$APP_PROMPT_PARAMETER=false")) {
if (newQueryFragment.isNotEmpty()) {
append("&")
}
append("$APP_PROMPT_PARAMETER=false")
}
if (!newQueryFragment.contains("$CONFINE_TO_ROOM_PARAMETER=true")) {
append("&$CONFINE_TO_ROOM_PARAMETER=true")
}
}
}
// We do not want to encode the Fragment part, so append it manually
return builder.build().toString() + "#" + newFragment
}
private const val APP_PROMPT_PARAMETER = "appPrompt"
private const val CONFINE_TO_ROOM_PARAMETER = "confineToRoom"

View file

@ -12,21 +12,21 @@ import android.app.PendingIntent
import android.content.Context import android.content.Context
import android.content.Intent import android.content.Intent
import androidx.core.app.PendingIntentCompat import androidx.core.app.PendingIntentCompat
import io.element.android.features.call.api.CallType import io.element.android.features.call.api.CallData
import io.element.android.features.call.impl.DefaultElementCallEntryPoint import io.element.android.features.call.impl.DefaultElementCallEntryPoint
import io.element.android.features.call.impl.ui.ElementCallActivity import io.element.android.features.call.impl.ui.ElementCallActivity
internal object IntentProvider { internal object IntentProvider {
fun createIntent(context: Context, callType: CallType): Intent = Intent(context, ElementCallActivity::class.java).apply { fun createIntent(context: Context, callData: CallData): Intent = Intent(context, ElementCallActivity::class.java).apply {
putExtra(DefaultElementCallEntryPoint.EXTRA_CALL_TYPE, callType) putExtra(DefaultElementCallEntryPoint.EXTRA_CALL_TYPE, callData)
addFlags(Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_NO_USER_ACTION) addFlags(Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_NO_USER_ACTION)
} }
fun getPendingIntent(context: Context, callType: CallType): PendingIntent { fun getPendingIntent(context: Context, callData: CallData): PendingIntent {
return PendingIntentCompat.getActivity( return PendingIntentCompat.getActivity(
context, context,
DefaultElementCallEntryPoint.REQUEST_CODE, DefaultElementCallEntryPoint.REQUEST_CODE,
createIntent(context, callType), createIntent(context, callData),
PendingIntent.FLAG_CANCEL_CURRENT, PendingIntent.FLAG_CANCEL_CURRENT,
false false
)!! )!!

View file

@ -140,26 +140,33 @@ class WebViewWidgetMessageInterceptor(
} }
} }
// Create a WebMessageListener, which will receive messages from the WebView and reply to them // Always register JavascriptInterface as the baseline message channel.
val webMessageListener = WebViewCompat.WebMessageListener { _, message, _, _, _ -> // This works on all WebView implementations including Huawei.
onMessageReceived(message.data) webView.addJavascriptInterface(object {
} @JavascriptInterface
fun postMessage(json: String?) {
onMessageReceived(json)
}
}, LISTENER_NAME)
// Use WebMessageListener if supported, otherwise use JavascriptInterface // Additionally register WebMessageListener on WebViews that reliably support it.
if (WebViewFeature.isFeatureSupported(WebViewFeature.WEB_MESSAGE_LISTENER)) { // Huawei WebView (Chromium < 119) reports WEB_MESSAGE_LISTENER as supported
// but silently drops messages, so we only trust it on Chromium 119+.
// See: https://github.com/element-hq/element-x-android/issues/6632
val webViewVersionName = WebViewCompat.getCurrentWebViewPackage(webView.context)?.versionName.orEmpty()
Timber.d("Using WebView version: $webViewVersionName")
val webViewVersionCode = webViewVersionName.split(".").firstOrNull()?.toIntOrNull() ?: 0
if (webViewVersionCode >= 119 &&
WebViewFeature.isFeatureSupported(WebViewFeature.WEB_MESSAGE_LISTENER)) {
WebViewCompat.addWebMessageListener( WebViewCompat.addWebMessageListener(
webView, webView,
LISTENER_NAME, LISTENER_NAME,
setOf("*"), setOf("*"),
webMessageListener WebViewCompat.WebMessageListener { _, message, _, _, _ ->
) onMessageReceived(message.data)
} else {
webView.addJavascriptInterface(object {
@JavascriptInterface
fun postMessage(json: String?) {
onMessageReceived(json)
} }
}, LISTENER_NAME) )
} }
} }

View file

@ -11,7 +11,7 @@ package io.element.android.features.call
import android.content.Intent import android.content.Intent
import androidx.test.platform.app.InstrumentationRegistry import androidx.test.platform.app.InstrumentationRegistry
import com.google.common.truth.Truth.assertThat import com.google.common.truth.Truth.assertThat
import io.element.android.features.call.api.CallType import io.element.android.features.call.api.CallData
import io.element.android.features.call.impl.DefaultElementCallEntryPoint import io.element.android.features.call.impl.DefaultElementCallEntryPoint
import io.element.android.features.call.impl.notifications.CallNotificationData import io.element.android.features.call.impl.notifications.CallNotificationData
import io.element.android.features.call.impl.ui.ElementCallActivity import io.element.android.features.call.impl.ui.ElementCallActivity
@ -37,7 +37,7 @@ class DefaultElementCallEntryPointTest {
@Test @Test
fun `startCall - starts ElementCallActivity setup with the needed extras`() = runTest { fun `startCall - starts ElementCallActivity setup with the needed extras`() = runTest {
val entryPoint = createEntryPoint() val entryPoint = createEntryPoint()
entryPoint.startCall(CallType.RoomCall(A_SESSION_ID, A_ROOM_ID, isAudioCall = false)) entryPoint.startCall(CallData(A_SESSION_ID, A_ROOM_ID, isAudioCall = false))
val expectedIntent = Intent(InstrumentationRegistry.getInstrumentation().targetContext, ElementCallActivity::class.java) val expectedIntent = Intent(InstrumentationRegistry.getInstrumentation().targetContext, ElementCallActivity::class.java)
val intent = shadowOf(RuntimeEnvironment.getApplication()).nextStartedActivity val intent = shadowOf(RuntimeEnvironment.getApplication()).nextStartedActivity
@ -53,7 +53,7 @@ class DefaultElementCallEntryPointTest {
val entryPoint = createEntryPoint(activeCallManager = activeCallManager) val entryPoint = createEntryPoint(activeCallManager = activeCallManager)
entryPoint.handleIncomingCall( entryPoint.handleIncomingCall(
callType = CallType.RoomCall(A_SESSION_ID, A_ROOM_ID, isAudioCall = false), callData = CallData(A_SESSION_ID, A_ROOM_ID, isAudioCall = false),
eventId = AN_EVENT_ID, eventId = AN_EVENT_ID,
senderId = A_USER_ID_2, senderId = A_USER_ID_2,
roomName = "roomName", roomName = "roomName",

View file

@ -8,11 +8,9 @@
package io.element.android.features.call.impl.pip package io.element.android.features.call.impl.pip
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.tests.testutils.lambda.lambdaRecorder import io.element.android.tests.testutils.lambda.lambdaRecorder
import io.element.android.tests.testutils.test
import kotlinx.coroutines.test.runTest import kotlinx.coroutines.test.runTest
import org.junit.Test import org.junit.Test
@ -20,9 +18,7 @@ class PictureInPicturePresenterTest {
@Test @Test
fun `when pip is not supported, the state value supportPip is false`() = runTest { fun `when pip is not supported, the state value supportPip is false`() = runTest {
val presenter = createPictureInPicturePresenter(supportPip = false) val presenter = createPictureInPicturePresenter(supportPip = false)
moleculeFlow(RecompositionMode.Immediate) { presenter.test {
presenter.present()
}.test {
val initialState = awaitItem() val initialState = awaitItem()
assertThat(initialState.supportPip).isFalse() assertThat(initialState.supportPip).isFalse()
} }
@ -35,9 +31,7 @@ class PictureInPicturePresenterTest {
supportPip = true, supportPip = true,
pipView = FakePipView(setPipParamsResult = { }), pipView = FakePipView(setPipParamsResult = { }),
) )
moleculeFlow(RecompositionMode.Immediate) { presenter.test {
presenter.present()
}.test {
val initialState = awaitItem() val initialState = awaitItem()
assertThat(initialState.supportPip).isTrue() assertThat(initialState.supportPip).isTrue()
} }
@ -53,18 +47,16 @@ class PictureInPicturePresenterTest {
enterPipModeResult = enterPipModeResult, enterPipModeResult = enterPipModeResult,
), ),
) )
moleculeFlow(RecompositionMode.Immediate) { presenter.test {
presenter.present()
}.test {
val initialState = awaitItem() val initialState = awaitItem()
assertThat(initialState.isInPictureInPicture).isFalse() assertThat(initialState.isInPictureInPicture).isFalse()
initialState.eventSink(PictureInPictureEvents.EnterPictureInPicture) initialState.eventSink(PictureInPictureEvent.EnterPictureInPicture)
enterPipModeResult.assertions().isCalledOnce() enterPipModeResult.assertions().isCalledOnce()
initialState.eventSink(PictureInPictureEvents.OnPictureInPictureModeChanged(true)) initialState.eventSink(PictureInPictureEvent.OnPictureInPictureModeChanged(true))
val pipState = awaitItem() val pipState = awaitItem()
assertThat(pipState.isInPictureInPicture).isTrue() assertThat(pipState.isInPictureInPicture).isTrue()
// User stops pip // User stops pip
initialState.eventSink(PictureInPictureEvents.OnPictureInPictureModeChanged(false)) initialState.eventSink(PictureInPictureEvent.OnPictureInPictureModeChanged(false))
val finalState = awaitItem() val finalState = awaitItem()
assertThat(finalState.isInPictureInPicture).isFalse() assertThat(finalState.isInPictureInPicture).isFalse()
} }
@ -80,12 +72,10 @@ class PictureInPicturePresenterTest {
handUpResult = handUpResult handUpResult = handUpResult
), ),
) )
moleculeFlow(RecompositionMode.Immediate) { presenter.test {
presenter.present()
}.test {
val initialState = awaitItem() val initialState = awaitItem()
initialState.eventSink(PictureInPictureEvents.SetPipController(FakePipController(canEnterPipResult = { false }))) initialState.eventSink(PictureInPictureEvent.SetPipController(FakePipController(canEnterPipResult = { false })))
initialState.eventSink(PictureInPictureEvents.EnterPictureInPicture) initialState.eventSink(PictureInPictureEvent.EnterPictureInPicture)
handUpResult.assertions().isCalledOnce() handUpResult.assertions().isCalledOnce()
} }
} }
@ -102,12 +92,10 @@ class PictureInPicturePresenterTest {
enterPipModeResult = enterPipModeResult enterPipModeResult = enterPipModeResult
), ),
) )
moleculeFlow(RecompositionMode.Immediate) { presenter.test {
presenter.present()
}.test {
val initialState = awaitItem() val initialState = awaitItem()
initialState.eventSink( initialState.eventSink(
PictureInPictureEvents.SetPipController( PictureInPictureEvent.SetPipController(
FakePipController( FakePipController(
canEnterPipResult = { true }, canEnterPipResult = { true },
enterPipResult = enterPipResult, enterPipResult = enterPipResult,
@ -115,16 +103,16 @@ class PictureInPicturePresenterTest {
) )
) )
) )
initialState.eventSink(PictureInPictureEvents.EnterPictureInPicture) initialState.eventSink(PictureInPictureEvent.EnterPictureInPicture)
enterPipModeResult.assertions().isCalledOnce() enterPipModeResult.assertions().isCalledOnce()
enterPipResult.assertions().isNeverCalled() enterPipResult.assertions().isNeverCalled()
initialState.eventSink(PictureInPictureEvents.OnPictureInPictureModeChanged(true)) initialState.eventSink(PictureInPictureEvent.OnPictureInPictureModeChanged(true))
val pipState = awaitItem() val pipState = awaitItem()
assertThat(pipState.isInPictureInPicture).isTrue() assertThat(pipState.isInPictureInPicture).isTrue()
enterPipResult.assertions().isCalledOnce() enterPipResult.assertions().isCalledOnce()
// User stops pip // User stops pip
exitPipResult.assertions().isNeverCalled() exitPipResult.assertions().isNeverCalled()
initialState.eventSink(PictureInPictureEvents.OnPictureInPictureModeChanged(false)) initialState.eventSink(PictureInPictureEvent.OnPictureInPictureModeChanged(false))
val finalState = awaitItem() val finalState = awaitItem()
assertThat(finalState.isInPictureInPicture).isFalse() assertThat(finalState.isInPictureInPicture).isFalse()
exitPipResult.assertions().isCalledOnce() exitPipResult.assertions().isCalledOnce()

View file

@ -0,0 +1,23 @@
/*
* Copyright (c) 2025 Element Creations Ltd.
* Copyright 2025 New Vector Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
* Please see LICENSE files in the repository root for full details.
*/
package io.element.android.features.call.ui
import com.google.common.truth.Truth.assertThat
import io.element.android.features.call.api.CallData
import io.element.android.libraries.matrix.test.A_ROOM_ID
import io.element.android.libraries.matrix.test.A_SESSION_ID
import org.junit.Test
class CallDataTest {
@Test
fun `RoomCall stringification does not contain the URL`() {
assertThat(CallData(A_SESSION_ID, A_ROOM_ID, false).toString())
.isEqualTo("CallData(sessionId=$A_SESSION_ID, roomId=$A_ROOM_ID, isAudioCall=false)")
}
}

View file

@ -0,0 +1,96 @@
/*
* Copyright (c) 2026 Element Creations Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
* Please see LICENSE files in the repository root for full details.
*/
package io.element.android.features.call.ui
import com.google.common.truth.Truth.assertThat
import io.element.android.features.call.impl.ui.CallScreenBackPressAction
import io.element.android.features.call.impl.ui.CallScreenBackPressPolicy
import org.junit.Test
class CallScreenBackPressPolicyTest {
@Test
fun `resolve returns dispatch escape when a web view is available and native button is pressed`() {
val result = CallScreenBackPressPolicy.resolve(
supportPip = false,
hasWebView = true,
fromNative = true,
)
assertThat(result).isEqualTo(CallScreenBackPressAction.DispatchEscapeToWebView)
}
@Test
fun `resolve dispatch escape when there is a web view and pip is supported on native button press`() {
val result = CallScreenBackPressPolicy.resolve(
supportPip = true,
hasWebView = true,
fromNative = true,
)
assertThat(result).isEqualTo(CallScreenBackPressAction.DispatchEscapeToWebView)
}
@Test
fun `resolve returns hangup when there is no web view and pip is not supported from native button`() {
val result = CallScreenBackPressPolicy.resolve(
supportPip = false,
hasWebView = false,
fromNative = true,
)
assertThat(result).isNull()
}
@Test
fun `resolve returns hangup when there is no web view even though pip is supported from native button`() {
val result = CallScreenBackPressPolicy.resolve(
supportPip = true,
hasWebView = false,
fromNative = true,
)
assertThat(result).isNull()
}
@Test
fun `resolve goes to pip if its not from native but from the webview`() {
val result = CallScreenBackPressPolicy.resolve(
supportPip = true,
hasWebView = true,
fromNative = false,
)
assertThat(result).isEqualTo(CallScreenBackPressAction.EnterPictureInPicture)
}
@Test
fun `resolve hangs up if its not from native but from the webview and pip is not supported`() {
val result = CallScreenBackPressPolicy.resolve(
supportPip = false,
hasWebView = true,
fromNative = false,
)
assertThat(result).isNull()
}
@Test
fun `invalid cases (event comes from webview but there is now webview) all result in hangup`() {
val withPipSupport = CallScreenBackPressPolicy.resolve(
supportPip = true,
hasWebView = false,
fromNative = false,
)
assertThat(withPipSupport).isNull()
val withOutPipSupport = CallScreenBackPressPolicy.resolve(
supportPip = false,
hasWebView = false,
fromNative = false,
)
assertThat(withOutPipSupport).isNull()
}
}

View file

@ -13,8 +13,8 @@ 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 im.vector.app.features.analytics.plan.MobileScreen import im.vector.app.features.analytics.plan.MobileScreen
import io.element.android.features.call.api.CallType import io.element.android.features.call.api.CallData
import io.element.android.features.call.impl.ui.CallScreenEvents import io.element.android.features.call.impl.ui.CallScreenEvent
import io.element.android.features.call.impl.ui.CallScreenNavigator import io.element.android.features.call.impl.ui.CallScreenNavigator
import io.element.android.features.call.impl.ui.CallScreenPresenter import io.element.android.features.call.impl.ui.CallScreenPresenter
import io.element.android.features.call.impl.utils.WidgetMessageSerializer import io.element.android.features.call.impl.utils.WidgetMessageSerializer
@ -39,6 +39,7 @@ import io.element.android.services.toolbox.api.systemclock.SystemClock
import io.element.android.tests.testutils.WarmUpRule import io.element.android.tests.testutils.WarmUpRule
import io.element.android.tests.testutils.lambda.lambdaRecorder import io.element.android.tests.testutils.lambda.lambdaRecorder
import io.element.android.tests.testutils.lambda.value import io.element.android.tests.testutils.lambda.value
import io.element.android.tests.testutils.test
import io.element.android.tests.testutils.testCoroutineDispatchers import io.element.android.tests.testutils.testCoroutineDispatchers
import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.cancelAndJoin import kotlinx.coroutines.cancelAndJoin
@ -59,46 +60,19 @@ class CallScreenPresenterTest {
val warmUpRule = WarmUpRule() val warmUpRule = WarmUpRule()
@Test @Test
fun `present - with CallType ExternalUrl just loads the URL and sets the call as active`() = runTest { fun `present - with CallData sets call as active, loads URL and runs WidgetDriver`() = runTest {
val analyticsLambda = lambdaRecorder<MobileScreen.ScreenName, Unit> {}
val joinedCallLambda = lambdaRecorder<CallType, Unit> {}
val presenter = createCallScreenPresenter(
callType = CallType.ExternalUrl("https://call.element.io"),
screenTracker = FakeScreenTracker(analyticsLambda),
activeCallManager = FakeActiveCallManager(joinedCallResult = joinedCallLambda),
)
moleculeFlow(RecompositionMode.Immediate) {
presenter.present()
}.test {
// Wait until the URL is loaded
advanceTimeBy(1.seconds)
skipItems(2)
val initialState = awaitItem()
assertThat(initialState.urlState).isEqualTo(AsyncData.Success("https://call.element.io"))
assertThat(initialState.webViewError).isNull()
assertThat(initialState.isInWidgetMode).isFalse()
assertThat(initialState.isCallActive).isFalse()
analyticsLambda.assertions().isNeverCalled()
joinedCallLambda.assertions().isCalledOnce()
}
}
@Test
fun `present - with CallType RoomCall sets call as active, loads URL and runs WidgetDriver`() = runTest {
val widgetDriver = FakeMatrixWidgetDriver() val widgetDriver = FakeMatrixWidgetDriver()
val widgetProvider = FakeCallWidgetProvider(widgetDriver) val widgetProvider = FakeCallWidgetProvider(widgetDriver)
val analyticsLambda = lambdaRecorder<MobileScreen.ScreenName, Unit> {} val analyticsLambda = lambdaRecorder<MobileScreen.ScreenName, Unit> {}
val joinedCallLambda = lambdaRecorder<CallType, Unit> {} val joinedCallLambda = lambdaRecorder<CallData, Unit> {}
val presenter = createCallScreenPresenter( val presenter = createCallScreenPresenter(
callType = CallType.RoomCall(A_SESSION_ID, A_ROOM_ID, false), callData = CallData(A_SESSION_ID, A_ROOM_ID, false),
widgetDriver = widgetDriver, widgetDriver = widgetDriver,
widgetProvider = widgetProvider, widgetProvider = widgetProvider,
screenTracker = FakeScreenTracker(analyticsLambda), screenTracker = FakeScreenTracker(analyticsLambda),
activeCallManager = FakeActiveCallManager(joinedCallResult = joinedCallLambda), activeCallManager = FakeActiveCallManager(joinedCallResult = joinedCallLambda),
) )
moleculeFlow(RecompositionMode.Immediate) { presenter.test {
presenter.present()
}.test {
// Wait until the URL is loaded // Wait until the URL is loaded
advanceTimeBy(1.seconds) advanceTimeBy(1.seconds)
skipItems(1) skipItems(1)
@ -107,7 +81,6 @@ class CallScreenPresenterTest {
val initialState = awaitItem() val initialState = awaitItem()
assertThat(initialState.urlState).isInstanceOf(AsyncData.Loading::class.java) assertThat(initialState.urlState).isInstanceOf(AsyncData.Loading::class.java)
assertThat(initialState.isCallActive).isFalse() assertThat(initialState.isCallActive).isFalse()
assertThat(initialState.isInWidgetMode).isTrue()
assertThat(widgetProvider.getWidgetCalled).isTrue() assertThat(widgetProvider.getWidgetCalled).isTrue()
assertThat(widgetDriver.runCalledCount).isEqualTo(1) assertThat(widgetDriver.runCalledCount).isEqualTo(1)
analyticsLambda.assertions().isCalledOnce().with(value(MobileScreen.ScreenName.RoomCall)) analyticsLambda.assertions().isCalledOnce().with(value(MobileScreen.ScreenName.RoomCall))
@ -123,19 +96,17 @@ class CallScreenPresenterTest {
fun `present - set message interceptor, send and receive messages`() = runTest { fun `present - set message interceptor, send and receive messages`() = runTest {
val widgetDriver = FakeMatrixWidgetDriver() val widgetDriver = FakeMatrixWidgetDriver()
val presenter = createCallScreenPresenter( val presenter = createCallScreenPresenter(
callType = CallType.RoomCall(A_SESSION_ID, A_ROOM_ID, false), callData = CallData(A_SESSION_ID, A_ROOM_ID, false),
widgetDriver = widgetDriver, widgetDriver = widgetDriver,
screenTracker = FakeScreenTracker {}, screenTracker = FakeScreenTracker {},
) )
val messageInterceptor = FakeWidgetMessageInterceptor() val messageInterceptor = FakeWidgetMessageInterceptor()
moleculeFlow(RecompositionMode.Immediate) { presenter.test {
presenter.present()
}.test {
// Give it time to load the URL and WidgetDriver // Give it time to load the URL and WidgetDriver
advanceTimeBy(1.seconds) advanceTimeBy(1.seconds)
val initialState = awaitItem() val initialState = awaitItem()
initialState.eventSink(CallScreenEvents.SetupMessageChannels(messageInterceptor)) initialState.eventSink(CallScreenEvent.SetupMessageChannels(messageInterceptor))
// And incoming message from the Widget Driver is passed to the WebView // And incoming message from the Widget Driver is passed to the WebView
widgetDriver.givenIncomingMessage("A message") widgetDriver.givenIncomingMessage("A message")
@ -154,24 +125,22 @@ class CallScreenPresenterTest {
val navigator = FakeCallScreenNavigator() val navigator = FakeCallScreenNavigator()
val widgetDriver = FakeMatrixWidgetDriver() val widgetDriver = FakeMatrixWidgetDriver()
val presenter = createCallScreenPresenter( val presenter = createCallScreenPresenter(
callType = CallType.RoomCall(A_SESSION_ID, A_ROOM_ID, false), callData = CallData(A_SESSION_ID, A_ROOM_ID, false),
widgetDriver = widgetDriver, widgetDriver = widgetDriver,
navigator = navigator, navigator = navigator,
dispatchers = testCoroutineDispatchers(useUnconfinedTestDispatcher = true), dispatchers = testCoroutineDispatchers(useUnconfinedTestDispatcher = true),
screenTracker = FakeScreenTracker {}, screenTracker = FakeScreenTracker {},
) )
val messageInterceptor = FakeWidgetMessageInterceptor() val messageInterceptor = FakeWidgetMessageInterceptor()
moleculeFlow(RecompositionMode.Immediate) { presenter.test {
presenter.present()
}.test {
val initialState = awaitItem() val initialState = awaitItem()
// Give it time to load the URL and WidgetDriver // Give it time to load the URL and WidgetDriver
advanceTimeBy(1.seconds) advanceTimeBy(1.seconds)
initialState.eventSink(CallScreenEvents.SetupMessageChannels(messageInterceptor)) initialState.eventSink(CallScreenEvent.SetupMessageChannels(messageInterceptor))
initialState.eventSink(CallScreenEvents.Hangup) initialState.eventSink(CallScreenEvent.Hangup)
// Let background coroutines run and the widget drive be received // Let background coroutines run and the widget drive be received
runCurrent() runCurrent()
@ -188,22 +157,20 @@ class CallScreenPresenterTest {
val navigator = FakeCallScreenNavigator() val navigator = FakeCallScreenNavigator()
val widgetDriver = FakeMatrixWidgetDriver() val widgetDriver = FakeMatrixWidgetDriver()
val presenter = createCallScreenPresenter( val presenter = createCallScreenPresenter(
callType = CallType.RoomCall(A_SESSION_ID, A_ROOM_ID, false), callData = CallData(A_SESSION_ID, A_ROOM_ID, false),
widgetDriver = widgetDriver, widgetDriver = widgetDriver,
navigator = navigator, navigator = navigator,
dispatchers = testCoroutineDispatchers(useUnconfinedTestDispatcher = true), dispatchers = testCoroutineDispatchers(useUnconfinedTestDispatcher = true),
screenTracker = FakeScreenTracker {}, screenTracker = FakeScreenTracker {},
) )
val messageInterceptor = FakeWidgetMessageInterceptor() val messageInterceptor = FakeWidgetMessageInterceptor()
moleculeFlow(RecompositionMode.Immediate) { presenter.test {
presenter.present()
}.test {
val initialState = awaitItem() val initialState = awaitItem()
// Give it time to load the URL and WidgetDriver // Give it time to load the URL and WidgetDriver
advanceTimeBy(1.seconds) advanceTimeBy(1.seconds)
initialState.eventSink(CallScreenEvents.SetupMessageChannels(messageInterceptor)) initialState.eventSink(CallScreenEvent.SetupMessageChannels(messageInterceptor))
messageInterceptor.givenInterceptedMessage("""{"action":"io.element.close","api":"fromWidget","widgetId":"1","requestId":"1"}""") messageInterceptor.givenInterceptedMessage("""{"action":"io.element.close","api":"fromWidget","widgetId":"1","requestId":"1"}""")
@ -223,22 +190,20 @@ class CallScreenPresenterTest {
val navigator = FakeCallScreenNavigator() val navigator = FakeCallScreenNavigator()
val widgetDriver = FakeMatrixWidgetDriver() val widgetDriver = FakeMatrixWidgetDriver()
val presenter = createCallScreenPresenter( val presenter = createCallScreenPresenter(
callType = CallType.RoomCall(A_SESSION_ID, A_ROOM_ID, false), callData = CallData(A_SESSION_ID, A_ROOM_ID, false),
widgetDriver = widgetDriver, widgetDriver = widgetDriver,
navigator = navigator, navigator = navigator,
dispatchers = testCoroutineDispatchers(useUnconfinedTestDispatcher = true), dispatchers = testCoroutineDispatchers(useUnconfinedTestDispatcher = true),
screenTracker = FakeScreenTracker {}, screenTracker = FakeScreenTracker {},
) )
val messageInterceptor = FakeWidgetMessageInterceptor() val messageInterceptor = FakeWidgetMessageInterceptor()
moleculeFlow(RecompositionMode.Immediate) { presenter.test {
presenter.present()
}.test {
// Give it time to load the URL and WidgetDriver // Give it time to load the URL and WidgetDriver
advanceTimeBy(1.seconds) advanceTimeBy(1.seconds)
skipItems(2) skipItems(2)
val initialState = awaitItem() val initialState = awaitItem()
assertThat(initialState.isCallActive).isFalse() assertThat(initialState.isCallActive).isFalse()
initialState.eventSink(CallScreenEvents.SetupMessageChannels(messageInterceptor)) initialState.eventSink(CallScreenEvent.SetupMessageChannels(messageInterceptor))
messageInterceptor.givenInterceptedMessage( messageInterceptor.givenInterceptedMessage(
""" """
{ {
@ -260,22 +225,20 @@ class CallScreenPresenterTest {
val navigator = FakeCallScreenNavigator() val navigator = FakeCallScreenNavigator()
val widgetDriver = FakeMatrixWidgetDriver() val widgetDriver = FakeMatrixWidgetDriver()
val presenter = createCallScreenPresenter( val presenter = createCallScreenPresenter(
callType = CallType.RoomCall(A_SESSION_ID, A_ROOM_ID, false), callData = CallData(A_SESSION_ID, A_ROOM_ID, false),
widgetDriver = widgetDriver, widgetDriver = widgetDriver,
navigator = navigator, navigator = navigator,
dispatchers = testCoroutineDispatchers(useUnconfinedTestDispatcher = true), dispatchers = testCoroutineDispatchers(useUnconfinedTestDispatcher = true),
screenTracker = FakeScreenTracker {}, screenTracker = FakeScreenTracker {},
) )
val messageInterceptor = FakeWidgetMessageInterceptor() val messageInterceptor = FakeWidgetMessageInterceptor()
moleculeFlow(RecompositionMode.Immediate) { presenter.test {
presenter.present()
}.test {
// Give it time to load the URL and WidgetDriver // Give it time to load the URL and WidgetDriver
advanceTimeBy(1.seconds) advanceTimeBy(1.seconds)
skipItems(2) skipItems(2)
val initialState = awaitItem() val initialState = awaitItem()
assertThat(initialState.isCallActive).isFalse() assertThat(initialState.isCallActive).isFalse()
initialState.eventSink(CallScreenEvents.SetupMessageChannels(messageInterceptor)) initialState.eventSink(CallScreenEvent.SetupMessageChannels(messageInterceptor))
skipItems(2) skipItems(2)
// Wait for the timeout to trigger // Wait for the timeout to trigger
@ -300,7 +263,7 @@ class CallScreenPresenterTest {
val matrixClient = FakeMatrixClient(syncService = syncService) val matrixClient = FakeMatrixClient(syncService = syncService)
val appForegroundStateService = FakeAppForegroundStateService() val appForegroundStateService = FakeAppForegroundStateService()
val presenter = createCallScreenPresenter( val presenter = createCallScreenPresenter(
callType = CallType.RoomCall(A_SESSION_ID, A_ROOM_ID, false), callData = CallData(A_SESSION_ID, A_ROOM_ID, false),
widgetDriver = widgetDriver, widgetDriver = widgetDriver,
navigator = navigator, navigator = navigator,
dispatchers = testCoroutineDispatchers(useUnconfinedTestDispatcher = true), dispatchers = testCoroutineDispatchers(useUnconfinedTestDispatcher = true),
@ -338,53 +301,8 @@ class CallScreenPresenterTest {
} }
} }
@Test
fun `present - error from WebView are updating the state`() = runTest {
val presenter = createCallScreenPresenter(
callType = CallType.ExternalUrl("https://call.element.io"),
activeCallManager = FakeActiveCallManager(),
)
moleculeFlow(RecompositionMode.Immediate) {
presenter.present()
}.test {
// Wait until the URL is loaded
advanceTimeBy(1.seconds)
skipItems(2)
val initialState = awaitItem()
initialState.eventSink(CallScreenEvents.OnWebViewError("A Webview error"))
val finalState = awaitItem()
assertThat(finalState.webViewError).isEqualTo("A Webview error")
}
}
@Test
fun `present - error from WebView are ignored if Element Call is loaded`() = runTest {
val presenter = createCallScreenPresenter(
callType = CallType.ExternalUrl("https://call.element.io"),
activeCallManager = FakeActiveCallManager(),
)
moleculeFlow(RecompositionMode.Immediate) {
presenter.present()
}.test {
// Wait until the URL is loaded
skipItems(1)
val initialState = awaitItem()
val messageInterceptor = FakeWidgetMessageInterceptor()
initialState.eventSink(CallScreenEvents.SetupMessageChannels(messageInterceptor))
// Emit a message
messageInterceptor.givenInterceptedMessage("A message")
// WebView emits an error, but it will be ignored
initialState.eventSink(CallScreenEvents.OnWebViewError("A Webview error"))
val finalState = awaitItem()
assertThat(finalState.webViewError).isNull()
cancelAndIgnoreRemainingEvents()
}
}
private fun TestScope.createCallScreenPresenter( private fun TestScope.createCallScreenPresenter(
callType: CallType, callData: CallData,
navigator: CallScreenNavigator = FakeCallScreenNavigator(), navigator: CallScreenNavigator = FakeCallScreenNavigator(),
widgetDriver: FakeMatrixWidgetDriver = FakeMatrixWidgetDriver(), widgetDriver: FakeMatrixWidgetDriver = FakeMatrixWidgetDriver(),
widgetProvider: FakeCallWidgetProvider = FakeCallWidgetProvider(widgetDriver), widgetProvider: FakeCallWidgetProvider = FakeCallWidgetProvider(widgetDriver),
@ -401,7 +319,7 @@ class CallScreenPresenterTest {
} }
val clock = SystemClock { 0 } val clock = SystemClock { 0 }
return CallScreenPresenter( return CallScreenPresenter(
callType = callType, callData = callData,
navigator = navigator, navigator = navigator,
callWidgetProvider = widgetProvider, callWidgetProvider = widgetProvider,
userAgentProvider = userAgentProvider, userAgentProvider = userAgentProvider,

View file

@ -0,0 +1,148 @@
/*
* Copyright (c) 2026 Element Creations Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
* Please see LICENSE files in the repository root for full details.
*/
@file:OptIn(ExperimentalTestApi::class)
package io.element.android.features.call.ui
import android.view.KeyEvent
import android.webkit.WebView
import androidx.activity.ComponentActivity
import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.ui.platform.LocalInspectionMode
import androidx.compose.ui.test.AndroidComposeUiTest
import androidx.compose.ui.test.ExperimentalTestApi
import androidx.compose.ui.test.v2.runAndroidComposeUiTest
import androidx.test.ext.junit.runners.AndroidJUnit4
import io.element.android.features.call.impl.pip.PictureInPictureEvent
import io.element.android.features.call.impl.pip.aPictureInPictureState
import io.element.android.features.call.impl.ui.CallScreenEvent
import io.element.android.features.call.impl.ui.CallScreenView
import io.element.android.features.call.impl.ui.JavascriptBackHandler
import io.element.android.features.call.impl.ui.aCallScreenState
import io.element.android.tests.testutils.EventsRecorder
import io.element.android.tests.testutils.pressBackKey
import org.junit.Assert.assertEquals
import org.junit.Test
import org.junit.runner.RunWith
import org.robolectric.annotation.Config
import org.robolectric.annotation.Implementation
import org.robolectric.annotation.Implements
import org.robolectric.annotation.Resetter
import org.robolectric.shadows.ShadowWebView
@RunWith(AndroidJUnit4::class)
class CallScreenViewTest {
@Test
fun `pressing back key triggers hangup when no web view is available and pip is unsupported`() = runAndroidComposeUiTest {
val callEvents = EventsRecorder<CallScreenEvent>()
setCallScreenView(
state = aCallScreenState(eventSink = callEvents),
useInspectionMode = true,
)
pressBackKey()
callEvents.assertEmpty()
}
@Config(shadows = [RecordingShadowWebView::class])
@Test
fun `pressing back key dispatches escape key events to web view when pip is unsupported`() = runAndroidComposeUiTest {
setCallScreenView(
state = aCallScreenState(),
useInspectionMode = false,
)
pressBackKey()
val dispatchedEvents = RecordingShadowWebView.dispatchedEvents
assertEquals(2, dispatchedEvents.size)
assertEquals(KeyEvent.ACTION_DOWN, dispatchedEvents[0].action)
assertEquals(KeyEvent.KEYCODE_ESCAPE, dispatchedEvents[0].keyCode)
assertEquals(KeyEvent.ACTION_UP, dispatchedEvents[1].action)
assertEquals(KeyEvent.KEYCODE_ESCAPE, dispatchedEvents[1].keyCode)
}
@Config(shadows = [RecordingShadowWebView::class])
@Test
fun `web view javascript back handler emits pip event when pip is supported`() = runAndroidComposeUiTest {
val pipEvents = EventsRecorder<PictureInPictureEvent>()
setCallScreenView(
state = aCallScreenState(),
useInspectionMode = false,
pipState = aPictureInPictureState(
supportPip = true,
eventSink = pipEvents,
),
)
runOnIdle {
RecordingShadowWebView.invokeJavascriptBackHandler()
}
pipEvents.assertSize(2)
pipEvents.assertTrue(0) { it is PictureInPictureEvent.SetPipController }
pipEvents.assertTrue(1) { it is PictureInPictureEvent.EnterPictureInPicture }
}
}
private fun AndroidComposeUiTest<ComponentActivity>.setCallScreenView(
state: io.element.android.features.call.impl.ui.CallScreenState,
useInspectionMode: Boolean,
pipState: io.element.android.features.call.impl.pip.PictureInPictureState = aPictureInPictureState(supportPip = false),
) {
setContent {
// Inspection mode disables AndroidView creation; keep it configurable per test.
CompositionLocalProvider(LocalInspectionMode provides useInspectionMode) {
CallScreenView(
state = state,
pipState = pipState,
onConsoleMessage = {},
requestPermissions = { _, _ -> },
)
}
}
}
@Implements(WebView::class)
internal class RecordingShadowWebView : ShadowWebView() {
companion object {
val dispatchedEvents = mutableListOf<KeyEvent>()
private var backHandlerJavascriptInterface: JavascriptBackHandler? = null
@Resetter
@JvmStatic
@Suppress("unused")
fun resetRecordedEvents() {
dispatchedEvents.clear()
backHandlerJavascriptInterface = null
}
fun invokeJavascriptBackHandler() {
val backHandler = checkNotNull(backHandlerJavascriptInterface) { "Expected backHandler JavaScript interface to be registered" }
backHandler.onBackPressed()
}
}
@Implementation
protected override fun addJavascriptInterface(`object`: Any, name: String) {
super.addJavascriptInterface(`object`, name)
if (name == "backHandler") {
backHandlerJavascriptInterface = `object` as? JavascriptBackHandler
}
}
@Implementation
@Suppress("unused")
fun dispatchKeyEvent(event: KeyEvent): Boolean {
dispatchedEvents += KeyEvent(event)
return false
}
}

View file

@ -1,45 +0,0 @@
/*
* Copyright (c) 2025 Element Creations Ltd.
* Copyright 2025 New Vector Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
* Please see LICENSE files in the repository root for full details.
*/
package io.element.android.features.call.ui
import com.google.common.truth.Truth.assertThat
import io.element.android.features.call.api.CallType
import io.element.android.features.call.impl.ui.getSessionId
import io.element.android.libraries.matrix.test.A_ROOM_ID
import io.element.android.libraries.matrix.test.A_SESSION_ID
import org.junit.Test
class CallTypeTest {
@Test
fun `getSessionId returns null for ExternalUrl`() {
assertThat(CallType.ExternalUrl("aURL").getSessionId()).isNull()
}
@Test
fun `getSessionId returns the sessionId for RoomCall`() {
assertThat(
CallType.RoomCall(
sessionId = A_SESSION_ID,
roomId = A_ROOM_ID,
isAudioCall = false,
).getSessionId()
).isEqualTo(A_SESSION_ID)
}
@Test
fun `ExternalUrl stringification does not contain the URL`() {
assertThat(CallType.ExternalUrl("aURL").toString()).isEqualTo("ExternalUrl")
}
@Test
fun `RoomCall stringification does not contain the URL`() {
assertThat(CallType.RoomCall(A_SESSION_ID, A_ROOM_ID, false).toString())
.isEqualTo("RoomCall(sessionId=$A_SESSION_ID, roomId=$A_ROOM_ID, isAudioCall=false)")
}
}

View file

@ -1,226 +0,0 @@
/*
* Copyright (c) 2025 Element Creations Ltd.
* Copyright 2023-2025 New Vector Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
* Please see LICENSE files in the repository root for full details.
*/
package io.element.android.features.call.utils
import com.google.common.truth.Truth.assertThat
import io.element.android.features.call.impl.utils.CallIntentDataParser
import org.junit.Test
import org.junit.runner.RunWith
import org.robolectric.RobolectricTestRunner
import java.net.URLEncoder
@RunWith(RobolectricTestRunner::class)
class CallIntentDataParserTest {
private val callIntentDataParser = CallIntentDataParser()
@Test
fun `a null data returns null`() {
val url: String? = null
assertThat(callIntentDataParser.parse(url)).isNull()
}
@Test
fun `empty data returns null`() {
doTest("", null)
}
@Test
fun `invalid data returns null`() {
doTest("!", null)
}
@Test
fun `data with no scheme returns null`() {
doTest("test", null)
}
@Test
fun `Element Call http urls returns null`() {
doTest("http://call.element.io", null)
doTest("http://call.element.io/some-actual-call?with=parameters", null)
}
@Test
fun `Element Call urls with unknown host returns null`() {
// Check valid host first, should not return null
doTest("https://call.element.io", "https://call.element.io#?appPrompt=false&confineToRoom=true")
// Unknown host should return null
doTest("https://unknown.io", null)
doTest("https://call.unknown.io", null)
doTest("https://call.element.com", null)
doTest("https://call.element.io.tld", null)
}
@Test
fun `Element Call urls will be returned as is`() {
doTest(
url = "https://call.element.io",
expectedResult = "https://call.element.io#?$EXTRA_PARAMS"
)
}
@Test
fun `Element Call url with url param gets url extracted`() {
doTest(
url = VALID_CALL_URL_WITH_PARAM,
expectedResult = "$VALID_CALL_URL_WITH_PARAM#?$EXTRA_PARAMS"
)
}
@Test
fun `HTTP and HTTPS urls that don't come from EC return null`() {
doTest("http://app.element.io", null)
doTest("https://app.element.io", null)
doTest("http://", null)
doTest("https://", null)
}
@Test
fun `Element Call url with no url returns null`() {
val embeddedUrl = VALID_CALL_URL_WITH_PARAM
val encodedUrl = URLEncoder.encode(embeddedUrl, "utf-8")
val url = "io.element.call:/?no_url=$encodedUrl"
assertThat(callIntentDataParser.parse(url)).isNull()
}
@Test
fun `element scheme with no call host returns null`() {
val embeddedUrl = VALID_CALL_URL_WITH_PARAM
val encodedUrl = URLEncoder.encode(embeddedUrl, "utf-8")
val url = "element://no-call?url=$encodedUrl"
assertThat(callIntentDataParser.parse(url)).isNull()
}
@Test
fun `element scheme with no data returns null`() {
val url = "element://call?url="
assertThat(callIntentDataParser.parse(url)).isNull()
}
@Test
fun `Element Call url with no data returns null`() {
val url = "io.element.call:/?url="
assertThat(callIntentDataParser.parse(url)).isNull()
}
@Test
fun `element invalid scheme returns null`() {
val embeddedUrl = VALID_CALL_URL_WITH_PARAM
val encodedUrl = URLEncoder.encode(embeddedUrl, "utf-8")
val url = "bad.scheme:/?url=$encodedUrl"
assertThat(callIntentDataParser.parse(url)).isNull()
}
@Test
fun `Element Call url with url extra param appPrompt gets url extracted`() {
doTest(
url = "$VALID_CALL_URL_WITH_PARAM&appPrompt=true",
expectedResult = "$VALID_CALL_URL_WITH_PARAM#?$EXTRA_PARAMS"
)
}
@Test
fun `Element Call url with url extra param in fragment appPrompt gets url extracted`() {
doTest(
url = "$VALID_CALL_URL_WITH_PARAM#?appPrompt=true",
expectedResult = "$VALID_CALL_URL_WITH_PARAM#?appPrompt=false&confineToRoom=true"
)
}
@Test
fun `Element Call url with url extra param in fragment appPrompt and other gets url extracted`() {
doTest(
url = "$VALID_CALL_URL_WITH_PARAM#?appPrompt=true&otherParam=maybe",
expectedResult = "$VALID_CALL_URL_WITH_PARAM#?appPrompt=false&otherParam=maybe&confineToRoom=true"
)
}
@Test
fun `Element Call url with url extra param confineToRoom gets url extracted`() {
doTest(
url = "$VALID_CALL_URL_WITH_PARAM&confineToRoom=false",
expectedResult = "$VALID_CALL_URL_WITH_PARAM#?$EXTRA_PARAMS"
)
}
@Test
fun `Element Call url with url extra param in fragment confineToRoom gets url extracted`() {
doTest(
url = "$VALID_CALL_URL_WITH_PARAM#?confineToRoom=false",
expectedResult = "$VALID_CALL_URL_WITH_PARAM#?confineToRoom=true&appPrompt=false"
)
}
@Test
fun `Element Call url with url extra param in fragment confineToRoom and more gets url extracted`() {
doTest(
url = "$VALID_CALL_URL_WITH_PARAM#?confineToRoom=false&otherParam=maybe",
expectedResult = "$VALID_CALL_URL_WITH_PARAM#?confineToRoom=true&otherParam=maybe&appPrompt=false"
)
}
@Test
fun `Element Call url with url fragment gets url extracted`() {
doTest(
url = "$VALID_CALL_URL_WITH_PARAM#fragment",
expectedResult = "$VALID_CALL_URL_WITH_PARAM#fragment?$EXTRA_PARAMS"
)
}
@Test
fun `Element Call url with url fragment with params gets url extracted`() {
doTest(
url = "$VALID_CALL_URL_WITH_PARAM#fragment?otherParam=maybe",
expectedResult = "$VALID_CALL_URL_WITH_PARAM#fragment?otherParam=maybe&$EXTRA_PARAMS"
)
}
@Test
fun `Element Call url with url fragment with other params gets url extracted`() {
doTest(
url = "$VALID_CALL_URL_WITH_PARAM#?otherParam=maybe",
expectedResult = "$VALID_CALL_URL_WITH_PARAM#?otherParam=maybe&$EXTRA_PARAMS"
)
}
@Test
fun `Element Call url with empty fragment`() {
doTest(
url = "$VALID_CALL_URL_WITH_PARAM#",
expectedResult = "$VALID_CALL_URL_WITH_PARAM#?$EXTRA_PARAMS"
)
}
@Test
fun `Element Call url with empty fragment query`() {
doTest(
url = "$VALID_CALL_URL_WITH_PARAM#?",
expectedResult = "$VALID_CALL_URL_WITH_PARAM#?$EXTRA_PARAMS"
)
}
private fun doTest(url: String, expectedResult: String?) {
// Test direct parsing
assertThat(callIntentDataParser.parse(url)).isEqualTo(expectedResult)
// Test embedded url, scheme 1
val encodedUrl = URLEncoder.encode(url, "utf-8")
val urlScheme1 = "element://call?url=$encodedUrl"
assertThat(callIntentDataParser.parse(urlScheme1)).isEqualTo(expectedResult)
// Test embedded url, scheme 2
val urlScheme2 = "io.element.call:/?url=$encodedUrl"
assertThat(callIntentDataParser.parse(urlScheme2)).isEqualTo(expectedResult)
}
companion object {
const val VALID_CALL_URL_WITH_PARAM = "https://call.element.io/some-actual-call?with=parameters"
const val EXTRA_PARAMS = "appPrompt=false&confineToRoom=true"
}
}

View file

@ -13,7 +13,7 @@ import androidx.core.app.NotificationManagerCompat
import androidx.core.content.getSystemService import androidx.core.content.getSystemService
import androidx.test.platform.app.InstrumentationRegistry import androidx.test.platform.app.InstrumentationRegistry
import com.google.common.truth.Truth.assertThat import com.google.common.truth.Truth.assertThat
import io.element.android.features.call.api.CallType import io.element.android.features.call.api.CallData
import io.element.android.features.call.impl.notifications.RingingCallNotificationCreator import io.element.android.features.call.impl.notifications.RingingCallNotificationCreator
import io.element.android.features.call.impl.notifications.aCallNotificationData import io.element.android.features.call.impl.notifications.aCallNotificationData
import io.element.android.features.call.impl.utils.ActiveCall import io.element.android.features.call.impl.utils.ActiveCall
@ -77,7 +77,7 @@ class DefaultActiveCallManagerTest {
assertThat(manager.activeCall.value).isEqualTo( assertThat(manager.activeCall.value).isEqualTo(
ActiveCall( ActiveCall(
callType = CallType.RoomCall( callData = CallData(
sessionId = callNotificationData.sessionId, sessionId = callNotificationData.sessionId,
roomId = callNotificationData.roomId, roomId = callNotificationData.roomId,
isAudioCall = false, isAudioCall = false,
@ -104,7 +104,7 @@ class DefaultActiveCallManagerTest {
assertThat(manager.activeCall.value).isEqualTo( assertThat(manager.activeCall.value).isEqualTo(
ActiveCall( ActiveCall(
callType = CallType.RoomCall( callData = CallData(
sessionId = callNotificationData.sessionId, sessionId = callNotificationData.sessionId,
roomId = callNotificationData.roomId, roomId = callNotificationData.roomId,
isAudioCall = true, isAudioCall = true,
@ -132,7 +132,7 @@ class DefaultActiveCallManagerTest {
manager.registerIncomingCall(aCallNotificationData(roomId = A_ROOM_ID_2)) manager.registerIncomingCall(aCallNotificationData(roomId = A_ROOM_ID_2))
assertThat(manager.activeCall.value).isEqualTo(activeCall) assertThat(manager.activeCall.value).isEqualTo(activeCall)
assertThat((manager.activeCall.value?.callType as? CallType.RoomCall)?.roomId).isNotEqualTo(A_ROOM_ID_2) assertThat(manager.activeCall.value?.callData?.roomId).isNotEqualTo(A_ROOM_ID_2)
advanceTimeBy(1) advanceTimeBy(1)
@ -178,7 +178,7 @@ class DefaultActiveCallManagerTest {
} }
@Test @Test
fun `hangUpCall - removes existing call if the CallType matches`() = runTest { fun `hangUpCall - removes existing call if the CallData matches`() = runTest {
setupShadowPowerManager() setupShadowPowerManager()
val notificationManagerCompat = mockk<NotificationManagerCompat>(relaxed = true) val notificationManagerCompat = mockk<NotificationManagerCompat>(relaxed = true)
val manager = createActiveCallManager(notificationManagerCompat = notificationManagerCompat) val manager = createActiveCallManager(notificationManagerCompat = notificationManagerCompat)
@ -188,7 +188,7 @@ class DefaultActiveCallManagerTest {
assertThat(manager.activeCall.value).isNotNull() assertThat(manager.activeCall.value).isNotNull()
assertThat(manager.activeWakeLock?.isHeld).isTrue() assertThat(manager.activeWakeLock?.isHeld).isTrue()
manager.hangUpCall(CallType.RoomCall(notificationData.sessionId, notificationData.roomId, false)) manager.hangUpCall(CallData(notificationData.sessionId, notificationData.roomId, false))
assertThat(manager.activeCall.value).isNull() assertThat(manager.activeCall.value).isNull()
assertThat(manager.activeWakeLock?.isHeld).isFalse() assertThat(manager.activeWakeLock?.isHeld).isFalse()
@ -215,7 +215,7 @@ class DefaultActiveCallManagerTest {
val notificationData = aCallNotificationData(roomId = A_ROOM_ID) val notificationData = aCallNotificationData(roomId = A_ROOM_ID)
manager.registerIncomingCall(notificationData) manager.registerIncomingCall(notificationData)
manager.hangUpCall(CallType.RoomCall(notificationData.sessionId, notificationData.roomId, false)) manager.hangUpCall(CallData(notificationData.sessionId, notificationData.roomId, false))
coVerify { coVerify {
room.declineCall(notificationEventId = notificationData.eventId) room.declineCall(notificationEventId = notificationData.eventId)
@ -242,7 +242,7 @@ class DefaultActiveCallManagerTest {
val notificationData = aCallNotificationData(roomId = A_ROOM_ID) val notificationData = aCallNotificationData(roomId = A_ROOM_ID)
// Do not register the incoming call, so the manager doesn't know about it // Do not register the incoming call, so the manager doesn't know about it
manager.hangUpCall( manager.hangUpCall(
callType = CallType.RoomCall(notificationData.sessionId, notificationData.roomId, false), callData = CallData(notificationData.sessionId, notificationData.roomId, false),
notificationData = notificationData, notificationData = notificationData,
) )
coVerify { coVerify {
@ -320,7 +320,7 @@ class DefaultActiveCallManagerTest {
} }
@Test @Test
fun `hangUpCall - does nothing if the CallType doesn't match`() = runTest { fun `hangUpCall - does nothing if the CallData doesn't match`() = runTest {
setupShadowPowerManager() setupShadowPowerManager()
val notificationManagerCompat = mockk<NotificationManagerCompat>(relaxed = true) val notificationManagerCompat = mockk<NotificationManagerCompat>(relaxed = true)
val manager = createActiveCallManager(notificationManagerCompat = notificationManagerCompat) val manager = createActiveCallManager(notificationManagerCompat = notificationManagerCompat)
@ -329,7 +329,13 @@ class DefaultActiveCallManagerTest {
assertThat(manager.activeCall.value).isNotNull() assertThat(manager.activeCall.value).isNotNull()
assertThat(manager.activeWakeLock?.isHeld).isTrue() assertThat(manager.activeWakeLock?.isHeld).isTrue()
manager.hangUpCall(CallType.ExternalUrl("https://example.com")) manager.hangUpCall(
CallData(
sessionId = A_SESSION_ID,
roomId = A_ROOM_ID_2,
isAudioCall = true,
)
)
assertThat(manager.activeCall.value).isNotNull() assertThat(manager.activeCall.value).isNotNull()
assertThat(manager.activeWakeLock?.isHeld).isTrue() assertThat(manager.activeWakeLock?.isHeld).isTrue()
@ -344,10 +350,10 @@ class DefaultActiveCallManagerTest {
val manager = createActiveCallManager(notificationManagerCompat = notificationManagerCompat) val manager = createActiveCallManager(notificationManagerCompat = notificationManagerCompat)
assertThat(manager.activeCall.value).isNull() assertThat(manager.activeCall.value).isNull()
manager.joinedCall(CallType.RoomCall(A_SESSION_ID, A_ROOM_ID, true)) manager.joinedCall(CallData(A_SESSION_ID, A_ROOM_ID, true))
assertThat(manager.activeCall.value).isEqualTo( assertThat(manager.activeCall.value).isEqualTo(
ActiveCall( ActiveCall(
callType = CallType.RoomCall( callData = CallData(
sessionId = A_SESSION_ID, sessionId = A_SESSION_ID,
roomId = A_ROOM_ID, roomId = A_ROOM_ID,
isAudioCall = true, isAudioCall = true,
@ -450,7 +456,7 @@ class DefaultActiveCallManagerTest {
assertThat(manager.activeCall.value).isEqualTo( assertThat(manager.activeCall.value).isEqualTo(
ActiveCall( ActiveCall(
callType = CallType.RoomCall( callData = CallData(
sessionId = callNotificationData.sessionId, sessionId = callNotificationData.sessionId,
roomId = callNotificationData.roomId, roomId = callNotificationData.roomId,
isAudioCall = false, isAudioCall = false,

View file

@ -8,7 +8,7 @@
package io.element.android.features.call.utils package io.element.android.features.call.utils
import io.element.android.features.call.api.CallType import io.element.android.features.call.api.CallData
import io.element.android.features.call.impl.notifications.CallNotificationData import io.element.android.features.call.impl.notifications.CallNotificationData
import io.element.android.features.call.impl.utils.ActiveCall import io.element.android.features.call.impl.utils.ActiveCall
import io.element.android.features.call.impl.utils.ActiveCallManager import io.element.android.features.call.impl.utils.ActiveCallManager
@ -17,8 +17,8 @@ import kotlinx.coroutines.flow.MutableStateFlow
class FakeActiveCallManager( class FakeActiveCallManager(
var registerIncomingCallResult: (CallNotificationData) -> Unit = {}, var registerIncomingCallResult: (CallNotificationData) -> Unit = {},
var hangUpCallResult: (CallType, CallNotificationData?) -> Unit = { _, _ -> }, var hangUpCallResult: (CallData, CallNotificationData?) -> Unit = { _, _ -> },
var joinedCallResult: (CallType) -> Unit = {}, var joinedCallResult: (CallData) -> Unit = {},
) : ActiveCallManager { ) : ActiveCallManager {
override val activeCall = MutableStateFlow<ActiveCall?>(null) override val activeCall = MutableStateFlow<ActiveCall?>(null)
@ -26,12 +26,12 @@ class FakeActiveCallManager(
registerIncomingCallResult(notificationData) registerIncomingCallResult(notificationData)
} }
override suspend fun hangUpCall(callType: CallType, notificationData: CallNotificationData?) = simulateLongTask { override suspend fun hangUpCall(callData: CallData, notificationData: CallNotificationData?) = simulateLongTask {
hangUpCallResult(callType, notificationData) hangUpCallResult(callData, notificationData)
} }
override suspend fun joinedCall(callType: CallType) = simulateLongTask { override suspend fun joinedCall(callData: CallData) = simulateLongTask {
joinedCallResult(callType) joinedCallResult(callData)
} }
fun setActiveCall(value: ActiveCall?) { fun setActiveCall(value: ActiveCall?) {

View file

@ -8,16 +8,16 @@
package io.element.android.features.call.test package io.element.android.features.call.test
import io.element.android.features.call.api.CallType import io.element.android.features.call.api.CallData
import io.element.android.features.call.api.ElementCallEntryPoint import io.element.android.features.call.api.ElementCallEntryPoint
import io.element.android.libraries.matrix.api.core.EventId import io.element.android.libraries.matrix.api.core.EventId
import io.element.android.libraries.matrix.api.core.UserId import io.element.android.libraries.matrix.api.core.UserId
import io.element.android.tests.testutils.lambda.lambdaError import io.element.android.tests.testutils.lambda.lambdaError
class FakeElementCallEntryPoint( class FakeElementCallEntryPoint(
var startCallResult: (CallType) -> Unit = { lambdaError() }, var startCallResult: (CallData) -> Unit = { lambdaError() },
var handleIncomingCallResult: ( var handleIncomingCallResult: (
CallType.RoomCall, CallData,
EventId, EventId,
UserId, UserId,
String?, String?,
@ -27,12 +27,12 @@ class FakeElementCallEntryPoint(
String?, String?,
) -> Unit = { _, _, _, _, _, _, _, _ -> lambdaError() } ) -> Unit = { _, _, _, _, _, _, _, _ -> lambdaError() }
) : ElementCallEntryPoint { ) : ElementCallEntryPoint {
override fun startCall(callType: CallType) { override fun startCall(callData: CallData) {
startCallResult(callType) startCallResult(callData)
} }
override suspend fun handleIncomingCall( override suspend fun handleIncomingCall(
callType: CallType.RoomCall, callData: CallData,
eventId: EventId, eventId: EventId,
senderId: UserId, senderId: UserId,
roomName: String?, roomName: String?,
@ -44,7 +44,7 @@ class FakeElementCallEntryPoint(
textContent: String?, textContent: String?,
) { ) {
handleIncomingCallResult( handleIncomingCallResult(
callType, callData,
eventId, eventId,
senderId, senderId,
roomName, roomName,

View file

@ -6,13 +6,16 @@
* Please see LICENSE files in the repository root for full details. * Please see LICENSE files in the repository root for full details.
*/ */
@file:OptIn(ExperimentalTestApi::class)
package io.element.android.features.logout.impl package io.element.android.features.logout.impl
import androidx.activity.ComponentActivity import androidx.activity.ComponentActivity
import androidx.compose.ui.test.junit4.AndroidComposeTestRule import androidx.compose.ui.test.AndroidComposeUiTest
import androidx.compose.ui.test.junit4.createAndroidComposeRule import androidx.compose.ui.test.ExperimentalTestApi
import androidx.compose.ui.test.onNodeWithTag import androidx.compose.ui.test.onNodeWithTag
import androidx.compose.ui.test.performTextInput import androidx.compose.ui.test.performTextInput
import androidx.compose.ui.test.v2.runAndroidComposeUiTest
import androidx.test.ext.junit.runners.AndroidJUnit4 import androidx.test.ext.junit.runners.AndroidJUnit4
import io.element.android.features.deactivation.impl.R import io.element.android.features.deactivation.impl.R
import io.element.android.libraries.architecture.AsyncAction import io.element.android.libraries.architecture.AsyncAction
@ -26,33 +29,29 @@ import io.element.android.tests.testutils.clickOn
import io.element.android.tests.testutils.ensureCalledOnce import io.element.android.tests.testutils.ensureCalledOnce
import io.element.android.tests.testutils.pressBack import io.element.android.tests.testutils.pressBack
import io.element.android.tests.testutils.pressTag import io.element.android.tests.testutils.pressTag
import org.junit.Rule
import org.junit.Test import org.junit.Test
import org.junit.rules.TestRule
import org.junit.runner.RunWith import org.junit.runner.RunWith
import org.robolectric.annotation.Config import org.robolectric.annotation.Config
@RunWith(AndroidJUnit4::class) @RunWith(AndroidJUnit4::class)
class AccountDeactivationViewTest { class AccountDeactivationViewTest {
@get:Rule val rule = createAndroidComposeRule<ComponentActivity>()
@Test @Test
fun `clicking on back invokes the expected callback`() { fun `clicking on back invokes the expected callback`() = runAndroidComposeUiTest {
val eventsRecorder = EventsRecorder<AccountDeactivationEvents>(expectEvents = false) val eventsRecorder = EventsRecorder<AccountDeactivationEvents>(expectEvents = false)
ensureCalledOnce { ensureCalledOnce {
rule.setAccountDeactivationView( setAccountDeactivationView(
state = anAccountDeactivationState(eventSink = eventsRecorder), state = anAccountDeactivationState(eventSink = eventsRecorder),
onBackClick = it, onBackClick = it,
) )
rule.pressBack() pressBack()
} }
} }
@Config(qualifiers = "h1024dp") @Config(qualifiers = "h1024dp")
@Test @Test
fun `clicking on Deactivate emits the expected Event`() { fun `clicking on Deactivate emits the expected Event`() = runAndroidComposeUiTest {
val eventsRecorder = EventsRecorder<AccountDeactivationEvents>() val eventsRecorder = EventsRecorder<AccountDeactivationEvents>()
rule.setAccountDeactivationView( setAccountDeactivationView(
state = anAccountDeactivationState( state = anAccountDeactivationState(
deactivateFormState = aDeactivateFormState( deactivateFormState = aDeactivateFormState(
password = A_PASSWORD, password = A_PASSWORD,
@ -60,14 +59,14 @@ class AccountDeactivationViewTest {
eventSink = eventsRecorder, eventSink = eventsRecorder,
), ),
) )
rule.clickOn(CommonStrings.action_delete) clickOn(CommonStrings.action_delete)
eventsRecorder.assertSingle(AccountDeactivationEvents.DeactivateAccount(false)) eventsRecorder.assertSingle(AccountDeactivationEvents.DeactivateAccount(false))
} }
@Test @Test
fun `clicking on Deactivate on the confirmation dialog emits the expected Event`() { fun `clicking on Deactivate on the confirmation dialog emits the expected Event`() = runAndroidComposeUiTest {
val eventsRecorder = EventsRecorder<AccountDeactivationEvents>() val eventsRecorder = EventsRecorder<AccountDeactivationEvents>()
rule.setAccountDeactivationView( setAccountDeactivationView(
state = anAccountDeactivationState( state = anAccountDeactivationState(
deactivateFormState = aDeactivateFormState( deactivateFormState = aDeactivateFormState(
password = A_PASSWORD, password = A_PASSWORD,
@ -76,14 +75,14 @@ class AccountDeactivationViewTest {
eventSink = eventsRecorder, eventSink = eventsRecorder,
), ),
) )
rule.pressTag(TestTags.dialogPositive.value) pressTag(TestTags.dialogPositive.value)
eventsRecorder.assertSingle(AccountDeactivationEvents.DeactivateAccount(false)) eventsRecorder.assertSingle(AccountDeactivationEvents.DeactivateAccount(false))
} }
@Test @Test
fun `clicking on retry on the confirmation dialog emits the expected Event`() { fun `clicking on retry on the confirmation dialog emits the expected Event`() = runAndroidComposeUiTest {
val eventsRecorder = EventsRecorder<AccountDeactivationEvents>() val eventsRecorder = EventsRecorder<AccountDeactivationEvents>()
rule.setAccountDeactivationView( setAccountDeactivationView(
state = anAccountDeactivationState( state = anAccountDeactivationState(
deactivateFormState = aDeactivateFormState( deactivateFormState = aDeactivateFormState(
password = A_PASSWORD, password = A_PASSWORD,
@ -92,26 +91,26 @@ class AccountDeactivationViewTest {
eventSink = eventsRecorder, eventSink = eventsRecorder,
), ),
) )
rule.clickOn(CommonStrings.action_retry) clickOn(CommonStrings.action_retry)
eventsRecorder.assertSingle(AccountDeactivationEvents.DeactivateAccount(true)) eventsRecorder.assertSingle(AccountDeactivationEvents.DeactivateAccount(true))
} }
@Test @Test
fun `switching on the erase all switch emits the expected Event`() { fun `switching on the erase all switch emits the expected Event`() = runAndroidComposeUiTest {
val eventsRecorder = EventsRecorder<AccountDeactivationEvents>() val eventsRecorder = EventsRecorder<AccountDeactivationEvents>()
rule.setAccountDeactivationView( setAccountDeactivationView(
state = anAccountDeactivationState( state = anAccountDeactivationState(
eventSink = eventsRecorder, eventSink = eventsRecorder,
), ),
) )
rule.clickOn(R.string.screen_deactivate_account_delete_all_messages) clickOn(R.string.screen_deactivate_account_delete_all_messages)
eventsRecorder.assertSingle(AccountDeactivationEvents.SetEraseData(true)) eventsRecorder.assertSingle(AccountDeactivationEvents.SetEraseData(true))
} }
@Test @Test
fun `switching off the erase all switch emits the expected Event`() { fun `switching off the erase all switch emits the expected Event`() = runAndroidComposeUiTest {
val eventsRecorder = EventsRecorder<AccountDeactivationEvents>() val eventsRecorder = EventsRecorder<AccountDeactivationEvents>()
rule.setAccountDeactivationView( setAccountDeactivationView(
state = anAccountDeactivationState( state = anAccountDeactivationState(
deactivateFormState = aDeactivateFormState( deactivateFormState = aDeactivateFormState(
eraseData = true, eraseData = true,
@ -119,15 +118,15 @@ class AccountDeactivationViewTest {
eventSink = eventsRecorder, eventSink = eventsRecorder,
), ),
) )
rule.clickOn(R.string.screen_deactivate_account_delete_all_messages) clickOn(R.string.screen_deactivate_account_delete_all_messages)
eventsRecorder.assertSingle(AccountDeactivationEvents.SetEraseData(false)) eventsRecorder.assertSingle(AccountDeactivationEvents.SetEraseData(false))
} }
@Config(qualifiers = "h1024dp") @Config(qualifiers = "h1024dp")
@Test @Test
fun `typing text in the password field emits the expected Event`() { fun `typing text in the password field emits the expected Event`() = runAndroidComposeUiTest {
val eventsRecorder = EventsRecorder<AccountDeactivationEvents>() val eventsRecorder = EventsRecorder<AccountDeactivationEvents>()
rule.setAccountDeactivationView( setAccountDeactivationView(
state = anAccountDeactivationState( state = anAccountDeactivationState(
deactivateFormState = aDeactivateFormState( deactivateFormState = aDeactivateFormState(
password = A_PASSWORD, password = A_PASSWORD,
@ -135,12 +134,12 @@ class AccountDeactivationViewTest {
eventSink = eventsRecorder, eventSink = eventsRecorder,
), ),
) )
rule.onNodeWithTag(TestTags.loginPassword.value).performTextInput("A") onNodeWithTag(TestTags.loginPassword.value).performTextInput("A")
eventsRecorder.assertSingle(AccountDeactivationEvents.SetPassword("A$A_PASSWORD")) eventsRecorder.assertSingle(AccountDeactivationEvents.SetPassword("A$A_PASSWORD"))
} }
} }
private fun <R : TestRule> AndroidComposeTestRule<R, ComponentActivity>.setAccountDeactivationView( private fun AndroidComposeUiTest<ComponentActivity>.setAccountDeactivationView(
state: AccountDeactivationState, state: AccountDeactivationState,
onBackClick: () -> Unit = EnsureNeverCalled(), onBackClick: () -> Unit = EnsureNeverCalled(),
) { ) {

View file

@ -15,6 +15,7 @@ android {
dependencies { dependencies {
api(projects.features.enterprise.api) api(projects.features.enterprise.api)
implementation(projects.libraries.architecture)
implementation(projects.libraries.compound) implementation(projects.libraries.compound)
implementation(projects.libraries.matrix.api) implementation(projects.libraries.matrix.api)
implementation(projects.tests.testutils) implementation(projects.tests.testutils)

View file

@ -6,11 +6,14 @@
* Please see LICENSE files in the repository root for full details. * Please see LICENSE files in the repository root for full details.
*/ */
@file:OptIn(ExperimentalTestApi::class)
package io.element.android.features.forward.impl package io.element.android.features.forward.impl
import androidx.activity.ComponentActivity import androidx.activity.ComponentActivity
import androidx.compose.ui.test.junit4.AndroidComposeTestRule import androidx.compose.ui.test.AndroidComposeUiTest
import androidx.compose.ui.test.junit4.createAndroidComposeRule import androidx.compose.ui.test.ExperimentalTestApi
import androidx.compose.ui.test.v2.runAndroidComposeUiTest
import androidx.test.ext.junit.runners.AndroidJUnit4 import androidx.test.ext.junit.runners.AndroidJUnit4
import io.element.android.libraries.architecture.AsyncAction import io.element.android.libraries.architecture.AsyncAction
import io.element.android.libraries.matrix.api.core.RoomId import io.element.android.libraries.matrix.api.core.RoomId
@ -21,34 +24,30 @@ import io.element.android.tests.testutils.EnsureNeverCalledWithParam
import io.element.android.tests.testutils.EventsRecorder import io.element.android.tests.testutils.EventsRecorder
import io.element.android.tests.testutils.ensureCalledOnceWithParam import io.element.android.tests.testutils.ensureCalledOnceWithParam
import io.element.android.tests.testutils.pressTag import io.element.android.tests.testutils.pressTag
import org.junit.Rule
import org.junit.Test import org.junit.Test
import org.junit.rules.TestRule
import org.junit.runner.RunWith import org.junit.runner.RunWith
@RunWith(AndroidJUnit4::class) @RunWith(AndroidJUnit4::class)
class ForwardMessagesViewTest { class ForwardMessagesViewTest {
@get:Rule val rule = createAndroidComposeRule<ComponentActivity>()
@Test @Test
fun `cancel error emits the expected event`() { fun `cancel error emits the expected event`() = runAndroidComposeUiTest {
val eventsRecorder = EventsRecorder<ForwardMessagesEvents>() val eventsRecorder = EventsRecorder<ForwardMessagesEvents>()
rule.setForwardMessagesView( setForwardMessagesView(
aForwardMessagesState( aForwardMessagesState(
forwardAction = AsyncAction.Failure(AN_EXCEPTION), forwardAction = AsyncAction.Failure(AN_EXCEPTION),
eventSink = eventsRecorder eventSink = eventsRecorder
), ),
) )
rule.pressTag(TestTags.dialogPositive.value) pressTag(TestTags.dialogPositive.value)
eventsRecorder.assertSingle(ForwardMessagesEvents.ClearError) eventsRecorder.assertSingle(ForwardMessagesEvents.ClearError)
} }
@Test @Test
fun `success invokes onForwardSuccess`() { fun `success invokes onForwardSuccess`() = runAndroidComposeUiTest {
val data = listOf(A_ROOM_ID) val data = listOf(A_ROOM_ID)
val eventsRecorder = EventsRecorder<ForwardMessagesEvents>(expectEvents = false) val eventsRecorder = EventsRecorder<ForwardMessagesEvents>(expectEvents = false)
ensureCalledOnceWithParam<List<RoomId>?>(data) { callback -> ensureCalledOnceWithParam<List<RoomId>?>(data) { callback ->
rule.setForwardMessagesView( setForwardMessagesView(
aForwardMessagesState( aForwardMessagesState(
forwardAction = AsyncAction.Success(data), forwardAction = AsyncAction.Success(data),
eventSink = eventsRecorder eventSink = eventsRecorder
@ -59,7 +58,7 @@ class ForwardMessagesViewTest {
} }
} }
private fun <R : TestRule> AndroidComposeTestRule<R, ComponentActivity>.setForwardMessagesView( private fun AndroidComposeUiTest<ComponentActivity>.setForwardMessagesView(
state: ForwardMessagesState, state: ForwardMessagesState,
onForwardSuccess: (List<RoomId>) -> Unit = EnsureNeverCalledWithParam(), onForwardSuccess: (List<RoomId>) -> Unit = EnsureNeverCalledWithParam(),
) { ) {

View file

@ -6,11 +6,14 @@
* Please see LICENSE files in the repository root for full details. * Please see LICENSE files in the repository root for full details.
*/ */
@file:OptIn(ExperimentalTestApi::class)
package io.element.android.features.ftue.impl.sessionverification.choosemode package io.element.android.features.ftue.impl.sessionverification.choosemode
import androidx.activity.ComponentActivity import androidx.activity.ComponentActivity
import androidx.compose.ui.test.junit4.AndroidComposeTestRule import androidx.compose.ui.test.AndroidComposeUiTest
import androidx.compose.ui.test.junit4.createAndroidComposeRule import androidx.compose.ui.test.ExperimentalTestApi
import androidx.compose.ui.test.v2.runAndroidComposeUiTest
import androidx.test.ext.junit.runners.AndroidJUnit4 import androidx.test.ext.junit.runners.AndroidJUnit4
import io.element.android.features.ftue.impl.R import io.element.android.features.ftue.impl.R
import io.element.android.libraries.architecture.AsyncData import io.element.android.libraries.architecture.AsyncData
@ -18,65 +21,61 @@ import io.element.android.libraries.ui.strings.CommonStrings
import io.element.android.tests.testutils.EnsureNeverCalled import io.element.android.tests.testutils.EnsureNeverCalled
import io.element.android.tests.testutils.clickOn import io.element.android.tests.testutils.clickOn
import io.element.android.tests.testutils.ensureCalledOnce import io.element.android.tests.testutils.ensureCalledOnce
import org.junit.Rule
import org.junit.Test import org.junit.Test
import org.junit.rules.TestRule
import org.junit.runner.RunWith import org.junit.runner.RunWith
import org.robolectric.annotation.Config import org.robolectric.annotation.Config
@RunWith(AndroidJUnit4::class) @RunWith(AndroidJUnit4::class)
class ChooseSessionVerificationModeViewTest { class ChooseSessionVerificationModeViewTest {
@get:Rule val rule = createAndroidComposeRule<ComponentActivity>()
@Config(qualifiers = "h1024dp") @Config(qualifiers = "h1024dp")
@Test @Test
fun `clicking on learn more invokes the expected callback`() { fun `clicking on learn more invokes the expected callback`() = runAndroidComposeUiTest {
ensureCalledOnce { callback -> ensureCalledOnce { callback ->
rule.setChooseSelfVerificationModeView( setChooseSelfVerificationModeView(
aChooseSelfVerificationModeState(), aChooseSelfVerificationModeState(),
onLearnMoreClick = callback, onLearnMoreClick = callback,
) )
rule.clickOn(CommonStrings.action_learn_more) clickOn(CommonStrings.action_learn_more)
} }
} }
@Config(qualifiers = "h1024dp") @Config(qualifiers = "h1024dp")
@Test @Test
fun `clicking on use another device calls the callback`() { fun `clicking on use another device calls the callback`() = runAndroidComposeUiTest {
ensureCalledOnce { callback -> ensureCalledOnce { callback ->
rule.setChooseSelfVerificationModeView( setChooseSelfVerificationModeView(
aChooseSelfVerificationModeState(AsyncData.Success(aButtonsState(canUseAnotherDevice = true))), aChooseSelfVerificationModeState(AsyncData.Success(aButtonsState(canUseAnotherDevice = true))),
onUseAnotherDevice = callback, onUseAnotherDevice = callback,
) )
rule.clickOn(R.string.screen_identity_use_another_device) clickOn(R.string.screen_identity_use_another_device)
} }
} }
@Config(qualifiers = "h1024dp") @Config(qualifiers = "h1024dp")
@Test @Test
fun `clicking on enter recovery key calls the callback`() { fun `clicking on enter recovery key calls the callback`() = runAndroidComposeUiTest {
ensureCalledOnce { callback -> ensureCalledOnce { callback ->
rule.setChooseSelfVerificationModeView( setChooseSelfVerificationModeView(
aChooseSelfVerificationModeState(AsyncData.Success(aButtonsState(canUseRecoveryKey = true))), aChooseSelfVerificationModeState(AsyncData.Success(aButtonsState(canUseRecoveryKey = true))),
onEnterRecoveryKey = callback, onEnterRecoveryKey = callback,
) )
rule.clickOn(R.string.screen_identity_confirmation_use_recovery_key) clickOn(R.string.screen_identity_confirmation_use_recovery_key)
} }
} }
@Config(qualifiers = "h1024dp") @Config(qualifiers = "h1024dp")
@Test @Test
fun `clicking on cannot confirm calls the reset keys callback`() { fun `clicking on cannot confirm calls the reset keys callback`() = runAndroidComposeUiTest {
ensureCalledOnce { callback -> ensureCalledOnce { callback ->
rule.setChooseSelfVerificationModeView( setChooseSelfVerificationModeView(
aChooseSelfVerificationModeState(), aChooseSelfVerificationModeState(),
onResetKey = callback, onResetKey = callback,
) )
rule.clickOn(R.string.screen_identity_confirmation_cannot_confirm) clickOn(R.string.screen_identity_confirmation_cannot_confirm)
} }
} }
private fun <R : TestRule> AndroidComposeTestRule<R, ComponentActivity>.setChooseSelfVerificationModeView( private fun AndroidComposeUiTest<ComponentActivity>.setChooseSelfVerificationModeView(
state: ChooseSelfVerificationModeState, state: ChooseSelfVerificationModeState,
onLearnMoreClick: () -> Unit = EnsureNeverCalled(), onLearnMoreClick: () -> Unit = EnsureNeverCalled(),
onUseAnotherDevice: () -> Unit = EnsureNeverCalled(), onUseAnotherDevice: () -> Unit = EnsureNeverCalled(),

View file

@ -46,6 +46,7 @@ dependencies {
implementation(projects.libraries.permissions.noop) implementation(projects.libraries.permissions.noop)
implementation(projects.libraries.preferences.api) implementation(projects.libraries.preferences.api)
implementation(projects.libraries.push.api) implementation(projects.libraries.push.api)
implementation(projects.libraries.sessionStorage.api)
implementation(projects.features.announcement.api) implementation(projects.features.announcement.api)
implementation(projects.features.invite.api) implementation(projects.features.invite.api)
implementation(projects.features.networkmonitor.api) implementation(projects.features.networkmonitor.api)

View file

@ -6,10 +6,13 @@
* Please see LICENSE files in the repository root for full details. * Please see LICENSE files in the repository root for full details.
*/ */
@file:OptIn(ExperimentalTestApi::class)
package io.element.android.features.home.impl.filters package io.element.android.features.home.impl.filters
import androidx.activity.ComponentActivity import androidx.activity.ComponentActivity
import androidx.compose.ui.test.junit4.createAndroidComposeRule import androidx.compose.ui.test.ExperimentalTestApi
import androidx.compose.ui.test.v2.runAndroidComposeUiTest
import androidx.test.ext.junit.runners.AndroidJUnit4 import androidx.test.ext.junit.runners.AndroidJUnit4
import io.element.android.features.home.impl.R import io.element.android.features.home.impl.R
import io.element.android.features.home.impl.filters.selection.FilterSelectionState import io.element.android.features.home.impl.filters.selection.FilterSelectionState
@ -17,23 +20,20 @@ import io.element.android.libraries.testtags.TestTags
import io.element.android.tests.testutils.EventsRecorder import io.element.android.tests.testutils.EventsRecorder
import io.element.android.tests.testutils.clickOn import io.element.android.tests.testutils.clickOn
import io.element.android.tests.testutils.pressTag import io.element.android.tests.testutils.pressTag
import org.junit.Rule
import org.junit.Test import org.junit.Test
import org.junit.runner.RunWith import org.junit.runner.RunWith
@RunWith(AndroidJUnit4::class) @RunWith(AndroidJUnit4::class)
class RoomListFiltersViewTest { class RoomListFiltersViewTest {
@get:Rule val rule = createAndroidComposeRule<ComponentActivity>()
@Test @Test
fun `clicking on filters generates expected Event`() { fun `clicking on filters generates expected Event`() = runAndroidComposeUiTest {
val eventsRecorder = EventsRecorder<RoomListFiltersEvent>() val eventsRecorder = EventsRecorder<RoomListFiltersEvent>()
rule.setContent { setContent {
RoomListFiltersView( RoomListFiltersView(
state = aRoomListFiltersState(eventSink = eventsRecorder), state = aRoomListFiltersState(eventSink = eventsRecorder),
) )
} }
rule.clickOn(R.string.screen_roomlist_filter_rooms) clickOn(R.string.screen_roomlist_filter_rooms)
eventsRecorder.assertList( eventsRecorder.assertList(
listOf( listOf(
RoomListFiltersEvent.ToggleFilter(RoomListFilter.Rooms), RoomListFiltersEvent.ToggleFilter(RoomListFilter.Rooms),
@ -42,9 +42,9 @@ class RoomListFiltersViewTest {
} }
@Test @Test
fun `clicking on clear filters generates expected Event`() { fun `clicking on clear filters generates expected Event`() = runAndroidComposeUiTest<ComponentActivity> {
val eventsRecorder = EventsRecorder<RoomListFiltersEvent>() val eventsRecorder = EventsRecorder<RoomListFiltersEvent>()
rule.setContent { setContent {
RoomListFiltersView( RoomListFiltersView(
state = aRoomListFiltersState( state = aRoomListFiltersState(
filterSelectionStates = RoomListFilter.entries.map { FilterSelectionState(it, isSelected = true) }, filterSelectionStates = RoomListFilter.entries.map { FilterSelectionState(it, isSelected = true) },
@ -52,7 +52,7 @@ class RoomListFiltersViewTest {
), ),
) )
} }
rule.pressTag(TestTags.homeScreenClearFilters.value) pressTag(TestTags.homeScreenClearFilters.value)
eventsRecorder.assertList( eventsRecorder.assertList(
listOf( listOf(
RoomListFiltersEvent.ClearSelectedFilters, RoomListFiltersEvent.ClearSelectedFilters,

View file

@ -6,11 +6,14 @@
* Please see LICENSE files in the repository root for full details. * Please see LICENSE files in the repository root for full details.
*/ */
@file:OptIn(ExperimentalTestApi::class)
package io.element.android.features.home.impl.roomlist package io.element.android.features.home.impl.roomlist
import androidx.activity.ComponentActivity import androidx.activity.ComponentActivity
import androidx.compose.ui.test.junit4.AndroidComposeTestRule import androidx.compose.ui.test.AndroidComposeUiTest
import androidx.compose.ui.test.junit4.createAndroidComposeRule import androidx.compose.ui.test.ExperimentalTestApi
import androidx.compose.ui.test.v2.runAndroidComposeUiTest
import androidx.test.ext.junit.runners.AndroidJUnit4 import androidx.test.ext.junit.runners.AndroidJUnit4
import io.element.android.features.home.impl.R import io.element.android.features.home.impl.R
import io.element.android.libraries.matrix.api.core.RoomId import io.element.android.libraries.matrix.api.core.RoomId
@ -20,23 +23,20 @@ import io.element.android.tests.testutils.EnsureNeverCalledWithParam
import io.element.android.tests.testutils.EventsRecorder import io.element.android.tests.testutils.EventsRecorder
import io.element.android.tests.testutils.clickOn import io.element.android.tests.testutils.clickOn
import io.element.android.tests.testutils.setSafeContent import io.element.android.tests.testutils.setSafeContent
import org.junit.Rule
import org.junit.Test import org.junit.Test
import org.junit.runner.RunWith import org.junit.runner.RunWith
@RunWith(AndroidJUnit4::class) @RunWith(AndroidJUnit4::class)
class RoomListContextMenuTest { class RoomListContextMenuTest {
@get:Rule val rule = createAndroidComposeRule<ComponentActivity>()
@Test @Test
fun `clicking on Mark as read generates expected Events`() { fun `clicking on Mark as read generates expected Events`() = runAndroidComposeUiTest {
val eventsRecorder = EventsRecorder<RoomListEvent>() val eventsRecorder = EventsRecorder<RoomListEvent>()
val contextMenu = aContextMenuShown(hasNewContent = true) val contextMenu = aContextMenuShown(hasNewContent = true)
rule.setRoomListContextMenu( setRoomListContextMenu(
contextMenu = contextMenu, contextMenu = contextMenu,
eventSink = eventsRecorder, eventSink = eventsRecorder,
) )
rule.clickOn(R.string.screen_roomlist_mark_as_read) clickOn(R.string.screen_roomlist_mark_as_read)
eventsRecorder.assertList( eventsRecorder.assertList(
listOf( listOf(
RoomListEvent.HideContextMenu, RoomListEvent.HideContextMenu,
@ -46,14 +46,14 @@ class RoomListContextMenuTest {
} }
@Test @Test
fun `clicking on Mark as unread generates expected Events`() { fun `clicking on Mark as unread generates expected Events`() = runAndroidComposeUiTest {
val eventsRecorder = EventsRecorder<RoomListEvent>() val eventsRecorder = EventsRecorder<RoomListEvent>()
val contextMenu = aContextMenuShown(hasNewContent = false) val contextMenu = aContextMenuShown(hasNewContent = false)
rule.setRoomListContextMenu( setRoomListContextMenu(
contextMenu = contextMenu, contextMenu = contextMenu,
eventSink = eventsRecorder, eventSink = eventsRecorder,
) )
rule.clickOn(R.string.screen_roomlist_mark_as_unread) clickOn(R.string.screen_roomlist_mark_as_unread)
eventsRecorder.assertList( eventsRecorder.assertList(
listOf( listOf(
RoomListEvent.HideContextMenu, RoomListEvent.HideContextMenu,
@ -63,14 +63,14 @@ class RoomListContextMenuTest {
} }
@Test @Test
fun `clicking on Leave room generates expected Events`() { fun `clicking on Leave room generates expected Events`() = runAndroidComposeUiTest {
val eventsRecorder = EventsRecorder<RoomListEvent>() val eventsRecorder = EventsRecorder<RoomListEvent>()
val contextMenu = aContextMenuShown(isDm = false) val contextMenu = aContextMenuShown(isDm = false)
rule.setRoomListContextMenu( setRoomListContextMenu(
contextMenu = contextMenu, contextMenu = contextMenu,
eventSink = eventsRecorder, eventSink = eventsRecorder,
) )
rule.clickOn(CommonStrings.action_leave_room) clickOn(CommonStrings.action_leave_room)
eventsRecorder.assertList( eventsRecorder.assertList(
listOf( listOf(
RoomListEvent.HideContextMenu, RoomListEvent.HideContextMenu,
@ -80,48 +80,48 @@ class RoomListContextMenuTest {
} }
@Test @Test
fun `clicking on Report room invokes the expected callback and generates expected Event`() { fun `clicking on Report room invokes the expected callback and generates expected Event`() = runAndroidComposeUiTest {
val eventsRecorder = EventsRecorder<RoomListEvent>() val eventsRecorder = EventsRecorder<RoomListEvent>()
val contextMenu = aContextMenuShown() val contextMenu = aContextMenuShown()
val callback = EnsureCalledOnceWithParam(contextMenu.roomId, Unit) val callback = EnsureCalledOnceWithParam(contextMenu.roomId, Unit)
rule.setRoomListContextMenu( setRoomListContextMenu(
contextMenu = contextMenu, contextMenu = contextMenu,
canReportRoom = true, canReportRoom = true,
eventSink = eventsRecorder, eventSink = eventsRecorder,
onRoomSettingsClick = EnsureNeverCalledWithParam(), onRoomSettingsClick = EnsureNeverCalledWithParam(),
onReportRoomClick = callback, onReportRoomClick = callback,
) )
rule.clickOn(CommonStrings.action_report_room) clickOn(CommonStrings.action_report_room)
eventsRecorder.assertSingle(RoomListEvent.HideContextMenu) eventsRecorder.assertSingle(RoomListEvent.HideContextMenu)
callback.assertSuccess() callback.assertSuccess()
} }
@Test @Test
fun `clicking on Settings invokes the expected callback and generates expected Event`() { fun `clicking on Settings invokes the expected callback and generates expected Event`() = runAndroidComposeUiTest {
val eventsRecorder = EventsRecorder<RoomListEvent>() val eventsRecorder = EventsRecorder<RoomListEvent>()
val contextMenu = aContextMenuShown() val contextMenu = aContextMenuShown()
val callback = EnsureCalledOnceWithParam(contextMenu.roomId, Unit) val callback = EnsureCalledOnceWithParam(contextMenu.roomId, Unit)
rule.setRoomListContextMenu( setRoomListContextMenu(
contextMenu = contextMenu, contextMenu = contextMenu,
eventSink = eventsRecorder, eventSink = eventsRecorder,
onRoomSettingsClick = callback, onRoomSettingsClick = callback,
) )
rule.clickOn(CommonStrings.common_settings) clickOn(CommonStrings.common_settings)
eventsRecorder.assertSingle(RoomListEvent.HideContextMenu) eventsRecorder.assertSingle(RoomListEvent.HideContextMenu)
callback.assertSuccess() callback.assertSuccess()
} }
@Test @Test
fun `clicking on Favourites generates expected Event`() { fun `clicking on Favourites generates expected Event`() = runAndroidComposeUiTest {
val eventsRecorder = EventsRecorder<RoomListEvent>() val eventsRecorder = EventsRecorder<RoomListEvent>()
val contextMenu = aContextMenuShown(isDm = false, isFavorite = false) val contextMenu = aContextMenuShown(isDm = false, isFavorite = false)
val callback = EnsureNeverCalledWithParam<RoomId>() val callback = EnsureNeverCalledWithParam<RoomId>()
rule.setRoomListContextMenu( setRoomListContextMenu(
contextMenu = contextMenu, contextMenu = contextMenu,
eventSink = eventsRecorder, eventSink = eventsRecorder,
onRoomSettingsClick = callback, onRoomSettingsClick = callback,
) )
rule.clickOn(CommonStrings.common_favourite) clickOn(CommonStrings.common_favourite)
eventsRecorder.assertList( eventsRecorder.assertList(
listOf( listOf(
RoomListEvent.SetRoomIsFavorite(contextMenu.roomId, true), RoomListEvent.SetRoomIsFavorite(contextMenu.roomId, true),
@ -129,7 +129,7 @@ class RoomListContextMenuTest {
) )
} }
private fun AndroidComposeTestRule<*, *>.setRoomListContextMenu( private fun AndroidComposeUiTest<ComponentActivity>.setRoomListContextMenu(
contextMenu: RoomListState.ContextMenu.Shown, contextMenu: RoomListState.ContextMenu.Shown,
canReportRoom: Boolean = false, canReportRoom: Boolean = false,
eventSink: (RoomListEvent) -> Unit, eventSink: (RoomListEvent) -> Unit,

View file

@ -6,10 +6,12 @@
* Please see LICENSE files in the repository root for full details. * Please see LICENSE files in the repository root for full details.
*/ */
@file:OptIn(ExperimentalTestApi::class)
package io.element.android.features.home.impl.roomlist package io.element.android.features.home.impl.roomlist
import androidx.activity.ComponentActivity import androidx.compose.ui.test.ExperimentalTestApi
import androidx.compose.ui.test.junit4.createAndroidComposeRule import androidx.compose.ui.test.v2.runAndroidComposeUiTest
import androidx.test.ext.junit.runners.AndroidJUnit4 import androidx.test.ext.junit.runners.AndroidJUnit4
import io.element.android.features.home.impl.model.aRoomListRoomSummary import io.element.android.features.home.impl.model.aRoomListRoomSummary
import io.element.android.libraries.ui.strings.CommonStrings import io.element.android.libraries.ui.strings.CommonStrings
@ -18,19 +20,16 @@ import io.element.android.tests.testutils.EnsureNeverCalledWithParam
import io.element.android.tests.testutils.EventsRecorder import io.element.android.tests.testutils.EventsRecorder
import io.element.android.tests.testutils.clickOn import io.element.android.tests.testutils.clickOn
import io.element.android.tests.testutils.setSafeContent import io.element.android.tests.testutils.setSafeContent
import org.junit.Rule
import org.junit.Test import org.junit.Test
import org.junit.runner.RunWith import org.junit.runner.RunWith
@RunWith(AndroidJUnit4::class) @RunWith(AndroidJUnit4::class)
class RoomListDeclineInviteMenuTest { class RoomListDeclineInviteMenuTest {
@get:Rule val rule = createAndroidComposeRule<ComponentActivity>()
@Test @Test
fun `clicking on decline emits the expected Events`() { fun `clicking on decline emits the expected Events`() = runAndroidComposeUiTest {
val eventsRecorder = EventsRecorder<RoomListEvent>() val eventsRecorder = EventsRecorder<RoomListEvent>()
val menu = RoomListState.DeclineInviteMenu.Shown(roomSummary = aRoomListRoomSummary()) val menu = RoomListState.DeclineInviteMenu.Shown(roomSummary = aRoomListRoomSummary())
rule.setSafeContent { setSafeContent {
RoomListDeclineInviteMenu( RoomListDeclineInviteMenu(
menu = menu, menu = menu,
canReportRoom = false, canReportRoom = false,
@ -38,7 +37,7 @@ class RoomListDeclineInviteMenuTest {
eventSink = eventsRecorder, eventSink = eventsRecorder,
) )
} }
rule.clickOn(CommonStrings.action_decline) clickOn(CommonStrings.action_decline)
eventsRecorder.assertList( eventsRecorder.assertList(
listOf( listOf(
RoomListEvent.HideDeclineInviteMenu, RoomListEvent.HideDeclineInviteMenu,
@ -48,10 +47,10 @@ class RoomListDeclineInviteMenuTest {
} }
@Test @Test
fun `clicking on decline and block when canReportRoom=true, it emits the expected Events and callback`() { fun `clicking on decline and block when canReportRoom=true, it emits the expected Events and callback`() = runAndroidComposeUiTest {
val eventsRecorder = EventsRecorder<RoomListEvent>() val eventsRecorder = EventsRecorder<RoomListEvent>()
val menu = RoomListState.DeclineInviteMenu.Shown(roomSummary = aRoomListRoomSummary()) val menu = RoomListState.DeclineInviteMenu.Shown(roomSummary = aRoomListRoomSummary())
rule.setSafeContent { setSafeContent {
RoomListDeclineInviteMenu( RoomListDeclineInviteMenu(
menu = menu, menu = menu,
canReportRoom = true, canReportRoom = true,
@ -59,16 +58,16 @@ class RoomListDeclineInviteMenuTest {
eventSink = eventsRecorder, eventSink = eventsRecorder,
) )
} }
rule.clickOn(CommonStrings.action_decline_and_block) clickOn(CommonStrings.action_decline_and_block)
val expectedEvents = listOf(RoomListEvent.HideDeclineInviteMenu) val expectedEvents = listOf(RoomListEvent.HideDeclineInviteMenu)
eventsRecorder.assertList(expectedEvents) eventsRecorder.assertList(expectedEvents)
} }
@Test @Test
fun `clicking on decline and block when canReportRoom=false, it emits the expected Events`() { fun `clicking on decline and block when canReportRoom=false, it emits the expected Events`() = runAndroidComposeUiTest {
val eventsRecorder = EventsRecorder<RoomListEvent>() val eventsRecorder = EventsRecorder<RoomListEvent>()
val menu = RoomListState.DeclineInviteMenu.Shown(roomSummary = aRoomListRoomSummary()) val menu = RoomListState.DeclineInviteMenu.Shown(roomSummary = aRoomListRoomSummary())
rule.setSafeContent { setSafeContent {
RoomListDeclineInviteMenu( RoomListDeclineInviteMenu(
menu = menu, menu = menu,
canReportRoom = false, canReportRoom = false,
@ -76,7 +75,7 @@ class RoomListDeclineInviteMenuTest {
eventSink = eventsRecorder, eventSink = eventsRecorder,
) )
} }
rule.clickOn(CommonStrings.action_decline_and_block) clickOn(CommonStrings.action_decline_and_block)
val expectedEvents = listOf( val expectedEvents = listOf(
RoomListEvent.HideDeclineInviteMenu, RoomListEvent.HideDeclineInviteMenu,
RoomListEvent.DeclineInvite(menu.roomSummary, blockUser = true), RoomListEvent.DeclineInvite(menu.roomSummary, blockUser = true),
@ -85,10 +84,10 @@ class RoomListDeclineInviteMenuTest {
} }
@Test @Test
fun `clicking on cancel emits the expected Event`() { fun `clicking on cancel emits the expected Event`() = runAndroidComposeUiTest {
val eventsRecorder = EventsRecorder<RoomListEvent>() val eventsRecorder = EventsRecorder<RoomListEvent>()
val menu = RoomListState.DeclineInviteMenu.Shown(roomSummary = aRoomListRoomSummary()) val menu = RoomListState.DeclineInviteMenu.Shown(roomSummary = aRoomListRoomSummary())
rule.setSafeContent { setSafeContent {
RoomListDeclineInviteMenu( RoomListDeclineInviteMenu(
menu = menu, menu = menu,
canReportRoom = false, canReportRoom = false,
@ -96,7 +95,7 @@ class RoomListDeclineInviteMenuTest {
eventSink = eventsRecorder, eventSink = eventsRecorder,
) )
} }
rule.clickOn(CommonStrings.action_cancel) clickOn(CommonStrings.action_cancel)
eventsRecorder.assertList(listOf(RoomListEvent.HideDeclineInviteMenu)) eventsRecorder.assertList(listOf(RoomListEvent.HideDeclineInviteMenu))
} }
} }

View file

@ -6,16 +6,19 @@
* Please see LICENSE files in the repository root for full details. * Please see LICENSE files in the repository root for full details.
*/ */
@file:OptIn(ExperimentalTestApi::class)
package io.element.android.features.home.impl.roomlist package io.element.android.features.home.impl.roomlist
import androidx.activity.ComponentActivity import androidx.activity.ComponentActivity
import androidx.compose.ui.test.junit4.AndroidComposeTestRule import androidx.compose.ui.test.AndroidComposeUiTest
import androidx.compose.ui.test.junit4.createAndroidComposeRule import androidx.compose.ui.test.ExperimentalTestApi
import androidx.compose.ui.test.longClick import androidx.compose.ui.test.longClick
import androidx.compose.ui.test.onNodeWithContentDescription import androidx.compose.ui.test.onNodeWithContentDescription
import androidx.compose.ui.test.onNodeWithText import androidx.compose.ui.test.onNodeWithText
import androidx.compose.ui.test.performClick import androidx.compose.ui.test.performClick
import androidx.compose.ui.test.performTouchInput import androidx.compose.ui.test.performTouchInput
import androidx.compose.ui.test.v2.runAndroidComposeUiTest
import androidx.test.ext.junit.runners.AndroidJUnit4 import androidx.test.ext.junit.runners.AndroidJUnit4
import io.element.android.features.home.impl.HomeView import io.element.android.features.home.impl.HomeView
import io.element.android.features.home.impl.R import io.element.android.features.home.impl.R
@ -32,22 +35,17 @@ import io.element.android.tests.testutils.clickOn
import io.element.android.tests.testutils.ensureCalledOnce import io.element.android.tests.testutils.ensureCalledOnce
import io.element.android.tests.testutils.ensureCalledOnceWithParam import io.element.android.tests.testutils.ensureCalledOnceWithParam
import io.element.android.tests.testutils.setSafeContent import io.element.android.tests.testutils.setSafeContent
import org.junit.Rule
import org.junit.Test import org.junit.Test
import org.junit.rules.TestRule
import org.junit.runner.RunWith import org.junit.runner.RunWith
import org.robolectric.annotation.Config import org.robolectric.annotation.Config
@RunWith(AndroidJUnit4::class) @RunWith(AndroidJUnit4::class)
class RoomListViewTest { class RoomListViewTest {
@get:Rule
val rule = createAndroidComposeRule<ComponentActivity>()
@Config(qualifiers = "h1024dp") @Config(qualifiers = "h1024dp")
@Test @Test
fun `displaying the view automatically sends a couple of UpdateVisibleRangeEvents`() { fun `displaying the view automatically sends a couple of UpdateVisibleRangeEvents`() = runAndroidComposeUiTest {
val eventsRecorder = EventsRecorder<RoomListEvent>() val eventsRecorder = EventsRecorder<RoomListEvent>()
rule.setRoomListView( setRoomListView(
state = aRoomListState( state = aRoomListState(
contentState = aRoomsContentState(securityBannerState = SecurityBannerState.RecoveryKeyConfirmation), contentState = aRoomsContentState(securityBannerState = SecurityBannerState.RecoveryKeyConfirmation),
eventSink = eventsRecorder, eventSink = eventsRecorder,
@ -62,9 +60,9 @@ class RoomListViewTest {
} }
@Test @Test
fun `clicking on close recovery key banner emits the expected Event`() { fun `clicking on close recovery key banner emits the expected Event`() = runAndroidComposeUiTest {
val eventsRecorder = EventsRecorder<RoomListEvent>() val eventsRecorder = EventsRecorder<RoomListEvent>()
rule.setRoomListView( setRoomListView(
state = aRoomListState( state = aRoomListState(
contentState = aRoomsContentState(securityBannerState = SecurityBannerState.RecoveryKeyConfirmation), contentState = aRoomsContentState(securityBannerState = SecurityBannerState.RecoveryKeyConfirmation),
eventSink = eventsRecorder, eventSink = eventsRecorder,
@ -74,15 +72,15 @@ class RoomListViewTest {
// Remove automatic initial events // Remove automatic initial events
eventsRecorder.clear() eventsRecorder.clear()
val close = rule.activity.getString(CommonStrings.action_close) val close = activity!!.getString(CommonStrings.action_close)
rule.onNodeWithContentDescription(close).performClick() onNodeWithContentDescription(close).performClick()
eventsRecorder.assertSingle(RoomListEvent.DismissBanner) eventsRecorder.assertSingle(RoomListEvent.DismissBanner)
} }
@Test @Test
fun `clicking on close setup key banner emits the expected Event`() { fun `clicking on close setup key banner emits the expected Event`() = runAndroidComposeUiTest {
val eventsRecorder = EventsRecorder<RoomListEvent>() val eventsRecorder = EventsRecorder<RoomListEvent>()
rule.setRoomListView( setRoomListView(
state = aRoomListState( state = aRoomListState(
contentState = aRoomsContentState(securityBannerState = SecurityBannerState.SetUpRecovery), contentState = aRoomsContentState(securityBannerState = SecurityBannerState.SetUpRecovery),
eventSink = eventsRecorder, eventSink = eventsRecorder,
@ -92,16 +90,16 @@ class RoomListViewTest {
// Remove automatic initial events // Remove automatic initial events
eventsRecorder.clear() eventsRecorder.clear()
val close = rule.activity.getString(CommonStrings.action_close) val close = activity!!.getString(CommonStrings.action_close)
rule.onNodeWithContentDescription(close).performClick() onNodeWithContentDescription(close).performClick()
eventsRecorder.assertSingle(RoomListEvent.DismissBanner) eventsRecorder.assertSingle(RoomListEvent.DismissBanner)
} }
@Test @Test
fun `clicking on continue recovery key banner invokes the expected callback`() { fun `clicking on continue recovery key banner invokes the expected callback`() = runAndroidComposeUiTest {
val eventsRecorder = EventsRecorder<RoomListEvent>() val eventsRecorder = EventsRecorder<RoomListEvent>()
ensureCalledOnce { callback -> ensureCalledOnce { callback ->
rule.setRoomListView( setRoomListView(
state = aRoomListState( state = aRoomListState(
contentState = aRoomsContentState(securityBannerState = SecurityBannerState.RecoveryKeyConfirmation), contentState = aRoomsContentState(securityBannerState = SecurityBannerState.RecoveryKeyConfirmation),
eventSink = eventsRecorder, eventSink = eventsRecorder,
@ -112,17 +110,17 @@ class RoomListViewTest {
// Remove automatic initial events // Remove automatic initial events
eventsRecorder.clear() eventsRecorder.clear()
rule.clickOn(CommonStrings.action_continue) clickOn(CommonStrings.action_continue)
eventsRecorder.assertEmpty() eventsRecorder.assertEmpty()
} }
} }
@Test @Test
fun `clicking on continue setup key banner invokes the expected callback`() { fun `clicking on continue setup key banner invokes the expected callback`() = runAndroidComposeUiTest {
val eventsRecorder = EventsRecorder<RoomListEvent>() val eventsRecorder = EventsRecorder<RoomListEvent>()
ensureCalledOnce { callback -> ensureCalledOnce { callback ->
rule.setRoomListView( setRoomListView(
state = aRoomListState( state = aRoomListState(
contentState = aRoomsContentState(securityBannerState = SecurityBannerState.SetUpRecovery), contentState = aRoomsContentState(securityBannerState = SecurityBannerState.SetUpRecovery),
eventSink = eventsRecorder, eventSink = eventsRecorder,
@ -131,28 +129,28 @@ class RoomListViewTest {
) )
// Remove automatic initial events // Remove automatic initial events
eventsRecorder.clear() eventsRecorder.clear()
rule.clickOn(R.string.banner_set_up_recovery_submit) clickOn(R.string.banner_set_up_recovery_submit)
eventsRecorder.assertEmpty() eventsRecorder.assertEmpty()
} }
} }
@Test @Test
fun `clicking on start chat when the session has no room invokes the expected callback`() { fun `clicking on start chat when the session has no room invokes the expected callback`() = runAndroidComposeUiTest {
val eventsRecorder = EventsRecorder<RoomListEvent>(expectEvents = false) val eventsRecorder = EventsRecorder<RoomListEvent>(expectEvents = false)
ensureCalledOnce { callback -> ensureCalledOnce { callback ->
rule.setRoomListView( setRoomListView(
state = aRoomListState( state = aRoomListState(
eventSink = eventsRecorder, eventSink = eventsRecorder,
contentState = anEmptyContentState(), contentState = anEmptyContentState(),
), ),
onCreateRoomClick = callback, onCreateRoomClick = callback,
) )
rule.clickOn(CommonStrings.action_start_chat) clickOn(CommonStrings.action_start_chat)
} }
} }
@Test @Test
fun `clicking on a room invokes the expected callback`() { fun `clicking on a room invokes the expected callback`() = runAndroidComposeUiTest {
val eventsRecorder = EventsRecorder<RoomListEvent>() val eventsRecorder = EventsRecorder<RoomListEvent>()
val state = aRoomListState( val state = aRoomListState(
eventSink = eventsRecorder, eventSink = eventsRecorder,
@ -161,7 +159,7 @@ class RoomListViewTest {
it.displayType == RoomSummaryDisplayType.ROOM it.displayType == RoomSummaryDisplayType.ROOM
} }
ensureCalledOnceWithParam(room0.roomId) { callback -> ensureCalledOnceWithParam(room0.roomId) { callback ->
rule.setRoomListView( setRoomListView(
state = state, state = state,
onRoomClick = callback, onRoomClick = callback,
) )
@ -169,14 +167,14 @@ class RoomListViewTest {
// Remove automatic initial events // Remove automatic initial events
eventsRecorder.clear() eventsRecorder.clear()
rule.onNodeWithText(room0.latestEvent.content().toString()).performClick() onNodeWithText(room0.latestEvent.content().toString()).performClick()
} }
eventsRecorder.assertEmpty() eventsRecorder.assertEmpty()
} }
@Test @Test
fun `clicking on a room twice invokes the expected callback only once`() { fun `clicking on a room twice invokes the expected callback only once`() = runAndroidComposeUiTest {
val eventsRecorder = EventsRecorder<RoomListEvent>() val eventsRecorder = EventsRecorder<RoomListEvent>()
val state = aRoomListState( val state = aRoomListState(
eventSink = eventsRecorder, eventSink = eventsRecorder,
@ -185,13 +183,13 @@ class RoomListViewTest {
it.displayType == RoomSummaryDisplayType.ROOM it.displayType == RoomSummaryDisplayType.ROOM
} }
ensureCalledOnceWithParam(room0.roomId) { callback -> ensureCalledOnceWithParam(room0.roomId) { callback ->
rule.setRoomListView( setRoomListView(
state = state, state = state,
onRoomClick = callback, onRoomClick = callback,
) )
// Remove automatic initial events // Remove automatic initial events
eventsRecorder.clear() eventsRecorder.clear()
rule.onNodeWithText(room0.latestEvent.content().toString()) onNodeWithText(room0.latestEvent.content().toString())
.performClick() .performClick()
.performClick() .performClick()
} }
@ -199,7 +197,7 @@ class RoomListViewTest {
} }
@Test @Test
fun `long clicking on a room emits the expected Event`() { fun `long clicking on a room emits the expected Event`() = runAndroidComposeUiTest {
val eventsRecorder = EventsRecorder<RoomListEvent>() val eventsRecorder = EventsRecorder<RoomListEvent>()
val state = aRoomListState( val state = aRoomListState(
eventSink = eventsRecorder, eventSink = eventsRecorder,
@ -207,18 +205,18 @@ class RoomListViewTest {
val room0 = state.contentAsRooms().summaries.first { val room0 = state.contentAsRooms().summaries.first {
it.displayType == RoomSummaryDisplayType.ROOM it.displayType == RoomSummaryDisplayType.ROOM
} }
rule.setRoomListView( setRoomListView(
state = state, state = state,
) )
// Remove automatic initial events // Remove automatic initial events
eventsRecorder.clear() eventsRecorder.clear()
rule.onNodeWithText(room0.latestEvent.content().toString()).performTouchInput { longClick() } onNodeWithText(room0.latestEvent.content().toString()).performTouchInput { longClick() }
eventsRecorder.assertSingle(RoomListEvent.ShowContextMenu(room0)) eventsRecorder.assertSingle(RoomListEvent.ShowContextMenu(room0))
} }
@Test @Test
fun `clicking on a room setting invokes the expected callback and emits expected Event`() { fun `clicking on a room setting invokes the expected callback and emits expected Event`() = runAndroidComposeUiTest {
val eventsRecorder = EventsRecorder<RoomListEvent>() val eventsRecorder = EventsRecorder<RoomListEvent>()
val state = aRoomListState( val state = aRoomListState(
contextMenu = aContextMenuShown(), contextMenu = aContextMenuShown(),
@ -226,7 +224,7 @@ class RoomListViewTest {
) )
val room0 = (state.contextMenu as RoomListState.ContextMenu.Shown).roomId val room0 = (state.contextMenu as RoomListState.ContextMenu.Shown).roomId
ensureCalledOnceWithParam(room0) { callback -> ensureCalledOnceWithParam(room0) { callback ->
rule.setRoomListView( setRoomListView(
state = state, state = state,
onRoomSettingsClick = callback, onRoomSettingsClick = callback,
) )
@ -234,14 +232,14 @@ class RoomListViewTest {
// Remove automatic initial events // Remove automatic initial events
eventsRecorder.clear() eventsRecorder.clear()
rule.clickOn(CommonStrings.common_settings) clickOn(CommonStrings.common_settings)
} }
eventsRecorder.assertSingle(RoomListEvent.HideContextMenu) eventsRecorder.assertSingle(RoomListEvent.HideContextMenu)
} }
@Test @Test
fun `clicking on accept and decline invite emits the expected Events`() { fun `clicking on accept and decline invite emits the expected Events`() = runAndroidComposeUiTest {
val eventsRecorder = EventsRecorder<RoomListEvent>() val eventsRecorder = EventsRecorder<RoomListEvent>()
val state = aRoomListState( val state = aRoomListState(
eventSink = eventsRecorder, eventSink = eventsRecorder,
@ -249,13 +247,13 @@ class RoomListViewTest {
val invitedRoom = state.contentAsRooms().summaries.first { val invitedRoom = state.contentAsRooms().summaries.first {
it.displayType == RoomSummaryDisplayType.INVITE it.displayType == RoomSummaryDisplayType.INVITE
} }
rule.setRoomListView(state = state) setRoomListView(state = state)
// Remove automatic initial events // Remove automatic initial events
eventsRecorder.clear() eventsRecorder.clear()
rule.clickOn(CommonStrings.action_accept) clickOn(CommonStrings.action_accept)
rule.clickOn(CommonStrings.action_decline) clickOn(CommonStrings.action_decline)
eventsRecorder.assertList( eventsRecorder.assertList(
listOf( listOf(
RoomListEvent.AcceptInvite(invitedRoom), RoomListEvent.AcceptInvite(invitedRoom),
@ -265,7 +263,7 @@ class RoomListViewTest {
} }
} }
private fun <R : TestRule> AndroidComposeTestRule<R, ComponentActivity>.setRoomListView( private fun AndroidComposeUiTest<ComponentActivity>.setRoomListView(
state: RoomListState, state: RoomListState,
onRoomClick: (RoomId) -> Unit = EnsureNeverCalledWithParam(), onRoomClick: (RoomId) -> Unit = EnsureNeverCalledWithParam(),
onSettingsClick: () -> Unit = EnsureNeverCalled(), onSettingsClick: () -> Unit = EnsureNeverCalled(),

View file

@ -5,34 +5,32 @@
* Please see LICENSE files in the repository root for full details. * Please see LICENSE files in the repository root for full details.
*/ */
@file:OptIn(ExperimentalTestApi::class)
package io.element.android.features.home.impl.spacefilters package io.element.android.features.home.impl.spacefilters
import androidx.activity.ComponentActivity import androidx.activity.ComponentActivity
import androidx.compose.ui.test.junit4.AndroidComposeTestRule import androidx.compose.ui.test.AndroidComposeUiTest
import androidx.compose.ui.test.junit4.createAndroidComposeRule import androidx.compose.ui.test.ExperimentalTestApi
import androidx.compose.ui.test.onNodeWithText import androidx.compose.ui.test.onNodeWithText
import androidx.compose.ui.test.performClick import androidx.compose.ui.test.performClick
import androidx.compose.ui.test.v2.runAndroidComposeUiTest
import androidx.test.ext.junit.runners.AndroidJUnit4 import androidx.test.ext.junit.runners.AndroidJUnit4
import io.element.android.libraries.matrix.test.A_ROOM_ALIAS import io.element.android.libraries.matrix.test.A_ROOM_ALIAS
import io.element.android.tests.testutils.EventsRecorder import io.element.android.tests.testutils.EventsRecorder
import org.junit.Rule
import org.junit.Test import org.junit.Test
import org.junit.rules.TestRule
import org.junit.runner.RunWith import org.junit.runner.RunWith
@RunWith(AndroidJUnit4::class) @RunWith(AndroidJUnit4::class)
class SpaceFiltersViewTest { class SpaceFiltersViewTest {
@get:Rule
val rule = createAndroidComposeRule<ComponentActivity>()
@Test @Test
fun `clicking on a filter with alias shows display name and alias`() { fun `clicking on a filter with alias shows display name and alias`() = runAndroidComposeUiTest {
val filter = aSpaceServiceFilter( val filter = aSpaceServiceFilter(
displayName = "Test Space", displayName = "Test Space",
canonicalAlias = A_ROOM_ALIAS, canonicalAlias = A_ROOM_ALIAS,
) )
val eventsRecorder = EventsRecorder<SpaceFiltersEvent.Selecting>() val eventsRecorder = EventsRecorder<SpaceFiltersEvent.Selecting>()
rule.setSpaceFiltersView( setSpaceFiltersView(
state = aSelectingSpaceFiltersState( state = aSelectingSpaceFiltersState(
availableFilters = listOf(filter), availableFilters = listOf(filter),
eventSink = eventsRecorder, eventSink = eventsRecorder,
@ -40,20 +38,20 @@ class SpaceFiltersViewTest {
) )
// Both display name and alias should be visible // Both display name and alias should be visible
rule.onNodeWithText(filter.spaceRoom.displayName).assertExists() onNodeWithText(filter.spaceRoom.displayName).assertExists()
rule.onNodeWithText(A_ROOM_ALIAS.value).assertExists() onNodeWithText(A_ROOM_ALIAS.value).assertExists()
rule.onNodeWithText(filter.spaceRoom.displayName).performClick() onNodeWithText(filter.spaceRoom.displayName).performClick()
eventsRecorder.assertSingle(SpaceFiltersEvent.Selecting.SelectFilter(filter)) eventsRecorder.assertSingle(SpaceFiltersEvent.Selecting.SelectFilter(filter))
} }
@Test @Test
fun `multiple filters are displayed and clickable`() { fun `multiple filters are displayed and clickable`() = runAndroidComposeUiTest {
val filter1 = aSpaceServiceFilter(displayName = "Space One") val filter1 = aSpaceServiceFilter(displayName = "Space One")
val filter2 = aSpaceServiceFilter(displayName = "Space Two") val filter2 = aSpaceServiceFilter(displayName = "Space Two")
val eventsRecorder = EventsRecorder<SpaceFiltersEvent.Selecting>() val eventsRecorder = EventsRecorder<SpaceFiltersEvent.Selecting>()
rule.setSpaceFiltersView( setSpaceFiltersView(
state = aSelectingSpaceFiltersState( state = aSelectingSpaceFiltersState(
availableFilters = listOf(filter1, filter2), availableFilters = listOf(filter1, filter2),
eventSink = eventsRecorder, eventSink = eventsRecorder,
@ -61,17 +59,17 @@ class SpaceFiltersViewTest {
) )
// Both filters should be visible // Both filters should be visible
rule.onNodeWithText(filter1.spaceRoom.displayName).assertExists() onNodeWithText(filter1.spaceRoom.displayName).assertExists()
rule.onNodeWithText(filter2.spaceRoom.displayName).assertExists() onNodeWithText(filter2.spaceRoom.displayName).assertExists()
// Click on second filter // Click on second filter
rule.onNodeWithText(filter2.spaceRoom.displayName).performClick() onNodeWithText(filter2.spaceRoom.displayName).performClick()
eventsRecorder.assertSingle(SpaceFiltersEvent.Selecting.SelectFilter(filter2)) eventsRecorder.assertSingle(SpaceFiltersEvent.Selecting.SelectFilter(filter2))
} }
} }
private fun <R : TestRule> AndroidComposeTestRule<R, ComponentActivity>.setSpaceFiltersView( private fun AndroidComposeUiTest<ComponentActivity>.setSpaceFiltersView(
state: SpaceFiltersState, state: SpaceFiltersState,
) { ) {
setContent { setContent {

View file

@ -33,6 +33,7 @@ dependencies {
implementation(projects.libraries.architecture) implementation(projects.libraries.architecture)
implementation(projects.libraries.matrix.api) implementation(projects.libraries.matrix.api)
implementation(projects.libraries.matrixui) implementation(projects.libraries.matrixui)
implementation(projects.libraries.sessionStorage.api)
implementation(projects.libraries.designsystem) implementation(projects.libraries.designsystem)
implementation(projects.libraries.uiStrings) implementation(projects.libraries.uiStrings)
implementation(projects.services.analytics.api) implementation(projects.services.analytics.api)

View file

@ -6,13 +6,16 @@
* Please see LICENSE files in the repository root for full details. * Please see LICENSE files in the repository root for full details.
*/ */
@file:OptIn(ExperimentalTestApi::class)
package io.element.android.features.invite.impl.declineandblock package io.element.android.features.invite.impl.declineandblock
import androidx.activity.ComponentActivity import androidx.activity.ComponentActivity
import androidx.compose.ui.test.junit4.AndroidComposeTestRule import androidx.compose.ui.test.AndroidComposeUiTest
import androidx.compose.ui.test.junit4.createAndroidComposeRule import androidx.compose.ui.test.ExperimentalTestApi
import androidx.compose.ui.test.onNodeWithText import androidx.compose.ui.test.onNodeWithText
import androidx.compose.ui.test.performTextInput import androidx.compose.ui.test.performTextInput
import androidx.compose.ui.test.v2.runAndroidComposeUiTest
import androidx.test.ext.junit.runners.AndroidJUnit4 import androidx.test.ext.junit.runners.AndroidJUnit4
import io.element.android.features.invite.impl.R import io.element.android.features.invite.impl.R
import io.element.android.libraries.ui.strings.CommonStrings import io.element.android.libraries.ui.strings.CommonStrings
@ -21,98 +24,94 @@ import io.element.android.tests.testutils.EventsRecorder
import io.element.android.tests.testutils.clickOn import io.element.android.tests.testutils.clickOn
import io.element.android.tests.testutils.ensureCalledOnce import io.element.android.tests.testutils.ensureCalledOnce
import io.element.android.tests.testutils.pressBack import io.element.android.tests.testutils.pressBack
import org.junit.Rule
import org.junit.Test import org.junit.Test
import org.junit.rules.TestRule
import org.junit.runner.RunWith import org.junit.runner.RunWith
@RunWith(AndroidJUnit4::class) @RunWith(AndroidJUnit4::class)
class DeclineAndBlockViewTest { class DeclineAndBlockViewTest {
@get:Rule val rule = createAndroidComposeRule<ComponentActivity>()
@Test @Test
fun `clicking on back invoke the expected callback`() { fun `clicking on back invoke the expected callback`() = runAndroidComposeUiTest {
val eventsRecorder = EventsRecorder<DeclineAndBlockEvents>(expectEvents = false) val eventsRecorder = EventsRecorder<DeclineAndBlockEvents>(expectEvents = false)
ensureCalledOnce { ensureCalledOnce {
rule.setDeclineAndBlockView( setDeclineAndBlockView(
aDeclineAndBlockState( aDeclineAndBlockState(
eventSink = eventsRecorder, eventSink = eventsRecorder,
), ),
onBackClick = it onBackClick = it
) )
rule.pressBack() pressBack()
} }
} }
@Test @Test
fun `clicking on decline when enabled emits the expected event`() { fun `clicking on decline when enabled emits the expected event`() = runAndroidComposeUiTest {
val eventsRecorder = EventsRecorder<DeclineAndBlockEvents>() val eventsRecorder = EventsRecorder<DeclineAndBlockEvents>()
rule.setDeclineAndBlockView( setDeclineAndBlockView(
aDeclineAndBlockState( aDeclineAndBlockState(
blockUser = true, blockUser = true,
eventSink = eventsRecorder, eventSink = eventsRecorder,
), ),
) )
rule.clickOn(CommonStrings.action_decline) clickOn(CommonStrings.action_decline)
eventsRecorder.assertSingle(DeclineAndBlockEvents.Decline) eventsRecorder.assertSingle(DeclineAndBlockEvents.Decline)
} }
@Test @Test
fun `clicking on decline when disabled does not emit event`() { fun `clicking on decline when disabled does not emit event`() = runAndroidComposeUiTest {
val eventsRecorder = EventsRecorder<DeclineAndBlockEvents>(expectEvents = false) val eventsRecorder = EventsRecorder<DeclineAndBlockEvents>(expectEvents = false)
rule.setDeclineAndBlockView( setDeclineAndBlockView(
aDeclineAndBlockState( aDeclineAndBlockState(
blockUser = false, blockUser = false,
reportRoom = false, reportRoom = false,
eventSink = eventsRecorder, eventSink = eventsRecorder,
), ),
) )
rule.clickOn(CommonStrings.action_decline) clickOn(CommonStrings.action_decline)
eventsRecorder.assertEmpty() eventsRecorder.assertEmpty()
} }
@Test @Test
fun `clicking on block option emits the expected event`() { fun `clicking on block option emits the expected event`() = runAndroidComposeUiTest {
val eventsRecorder = EventsRecorder<DeclineAndBlockEvents>() val eventsRecorder = EventsRecorder<DeclineAndBlockEvents>()
rule.setDeclineAndBlockView( setDeclineAndBlockView(
aDeclineAndBlockState( aDeclineAndBlockState(
blockUser = true, blockUser = true,
eventSink = eventsRecorder, eventSink = eventsRecorder,
), ),
) )
rule.clickOn(R.string.screen_decline_and_block_block_user_option_title) clickOn(R.string.screen_decline_and_block_block_user_option_title)
eventsRecorder.assertSingle(DeclineAndBlockEvents.ToggleBlockUser) eventsRecorder.assertSingle(DeclineAndBlockEvents.ToggleBlockUser)
} }
@Test @Test
fun `clicking on report room option emits the expected event`() { fun `clicking on report room option emits the expected event`() = runAndroidComposeUiTest {
val eventsRecorder = EventsRecorder<DeclineAndBlockEvents>() val eventsRecorder = EventsRecorder<DeclineAndBlockEvents>()
rule.setDeclineAndBlockView( setDeclineAndBlockView(
aDeclineAndBlockState( aDeclineAndBlockState(
reportRoom = true, reportRoom = true,
eventSink = eventsRecorder, eventSink = eventsRecorder,
), ),
) )
rule.clickOn(CommonStrings.action_report_room) clickOn(CommonStrings.action_report_room)
eventsRecorder.assertSingle(DeclineAndBlockEvents.ToggleReportRoom) eventsRecorder.assertSingle(DeclineAndBlockEvents.ToggleReportRoom)
} }
@Test @Test
fun `typing text in the reason field emits the expected Event`() { fun `typing text in the reason field emits the expected Event`() = runAndroidComposeUiTest {
val eventsRecorder = EventsRecorder<DeclineAndBlockEvents>() val eventsRecorder = EventsRecorder<DeclineAndBlockEvents>()
rule.setDeclineAndBlockView( setDeclineAndBlockView(
aDeclineAndBlockState( aDeclineAndBlockState(
reportRoom = true, reportRoom = true,
reportReason = "", reportReason = "",
eventSink = eventsRecorder, eventSink = eventsRecorder,
), ),
) )
rule.onNodeWithText("").performTextInput("Spam!") onNodeWithText("").performTextInput("Spam!")
eventsRecorder.assertSingle(DeclineAndBlockEvents.UpdateReportReason("Spam!")) eventsRecorder.assertSingle(DeclineAndBlockEvents.UpdateReportReason("Spam!"))
} }
} }
private fun <R : TestRule> AndroidComposeTestRule<R, ComponentActivity>.setDeclineAndBlockView( private fun AndroidComposeUiTest<ComponentActivity>.setDeclineAndBlockView(
state: DeclineAndBlockState, state: DeclineAndBlockState,
onBackClick: () -> Unit = EnsureNeverCalled(), onBackClick: () -> Unit = EnsureNeverCalled(),
) { ) {

View file

@ -16,6 +16,7 @@ android {
dependencies { dependencies {
implementation(libs.coroutines.core) implementation(libs.coroutines.core)
implementation(projects.libraries.architecture)
implementation(projects.libraries.matrix.api) implementation(projects.libraries.matrix.api)
implementation(projects.libraries.matrix.test) implementation(projects.libraries.matrix.test)
implementation(projects.tests.testutils) implementation(projects.tests.testutils)

View file

@ -6,11 +6,14 @@
* Please see LICENSE files in the repository root for full details. * Please see LICENSE files in the repository root for full details.
*/ */
@file:OptIn(ExperimentalTestApi::class)
package io.element.android.features.joinroom.impl package io.element.android.features.joinroom.impl
import androidx.activity.ComponentActivity import androidx.activity.ComponentActivity
import androidx.compose.ui.test.junit4.AndroidComposeTestRule import androidx.compose.ui.test.AndroidComposeUiTest
import androidx.compose.ui.test.junit4.createAndroidComposeRule import androidx.compose.ui.test.ExperimentalTestApi
import androidx.compose.ui.test.v2.runAndroidComposeUiTest
import androidx.test.ext.junit.runners.AndroidJUnit4 import androidx.test.ext.junit.runners.AndroidJUnit4
import io.element.android.features.invite.api.InviteData import io.element.android.features.invite.api.InviteData
import io.element.android.features.invite.test.anInviteData import io.element.android.features.invite.test.anInviteData
@ -26,116 +29,112 @@ import io.element.android.tests.testutils.clickOn
import io.element.android.tests.testutils.ensureCalledOnce import io.element.android.tests.testutils.ensureCalledOnce
import io.element.android.tests.testutils.ensureCalledOnceWithParam import io.element.android.tests.testutils.ensureCalledOnceWithParam
import io.element.android.tests.testutils.pressBack import io.element.android.tests.testutils.pressBack
import org.junit.Rule
import org.junit.Test import org.junit.Test
import org.junit.rules.TestRule
import org.junit.runner.RunWith import org.junit.runner.RunWith
@RunWith(AndroidJUnit4::class) @RunWith(AndroidJUnit4::class)
class JoinRoomViewTest { class JoinRoomViewTest {
@get:Rule val rule = createAndroidComposeRule<ComponentActivity>()
@Test @Test
fun `clicking on back invoke the expected callback`() { fun `clicking on back invoke the expected callback`() = runAndroidComposeUiTest {
val eventsRecorder = EventsRecorder<JoinRoomEvents>(expectEvents = false) val eventsRecorder = EventsRecorder<JoinRoomEvents>(expectEvents = false)
ensureCalledOnce { ensureCalledOnce {
rule.setJoinRoomView( setJoinRoomView(
aJoinRoomState( aJoinRoomState(
eventSink = eventsRecorder, eventSink = eventsRecorder,
), ),
onBackClick = it onBackClick = it
) )
rule.pressBack() pressBack()
} }
} }
@Test @Test
fun `clicking on Join room on CanJoin room emits the expected Event`() { fun `clicking on Join room on CanJoin room emits the expected Event`() = runAndroidComposeUiTest {
val eventsRecorder = EventsRecorder<JoinRoomEvents>() val eventsRecorder = EventsRecorder<JoinRoomEvents>()
rule.setJoinRoomView( setJoinRoomView(
aJoinRoomState( aJoinRoomState(
contentState = aLoadedContentState(joinAuthorisationStatus = JoinAuthorisationStatus.CanJoin), contentState = aLoadedContentState(joinAuthorisationStatus = JoinAuthorisationStatus.CanJoin),
eventSink = eventsRecorder, eventSink = eventsRecorder,
), ),
) )
rule.clickOn(R.string.screen_join_room_join_action) clickOn(R.string.screen_join_room_join_action)
eventsRecorder.assertSingle(JoinRoomEvents.JoinRoom) eventsRecorder.assertSingle(JoinRoomEvents.JoinRoom)
} }
@Test @Test
fun `clicking on Knock room on CanKnock room emits the expected Event`() { fun `clicking on Knock room on CanKnock room emits the expected Event`() = runAndroidComposeUiTest {
val eventsRecorder = EventsRecorder<JoinRoomEvents>() val eventsRecorder = EventsRecorder<JoinRoomEvents>()
rule.setJoinRoomView( setJoinRoomView(
aJoinRoomState( aJoinRoomState(
contentState = aLoadedContentState(joinAuthorisationStatus = JoinAuthorisationStatus.CanKnock), contentState = aLoadedContentState(joinAuthorisationStatus = JoinAuthorisationStatus.CanKnock),
knockMessage = "Knock knock", knockMessage = "Knock knock",
eventSink = eventsRecorder, eventSink = eventsRecorder,
), ),
) )
rule.clickOn(R.string.screen_join_room_knock_action) clickOn(R.string.screen_join_room_knock_action)
eventsRecorder.assertSingle(JoinRoomEvents.KnockRoom) eventsRecorder.assertSingle(JoinRoomEvents.KnockRoom)
} }
@Test @Test
fun `clicking on closing Knock error emits the expected Event`() { fun `clicking on closing Knock error emits the expected Event`() = runAndroidComposeUiTest {
val eventsRecorder = EventsRecorder<JoinRoomEvents>() val eventsRecorder = EventsRecorder<JoinRoomEvents>()
rule.setJoinRoomView( setJoinRoomView(
aJoinRoomState( aJoinRoomState(
contentState = aLoadedContentState(joinAuthorisationStatus = JoinAuthorisationStatus.CanKnock), contentState = aLoadedContentState(joinAuthorisationStatus = JoinAuthorisationStatus.CanKnock),
knockAction = AsyncAction.Failure(Exception("Error")), knockAction = AsyncAction.Failure(Exception("Error")),
eventSink = eventsRecorder, eventSink = eventsRecorder,
), ),
) )
rule.clickOn(CommonStrings.action_ok) clickOn(CommonStrings.action_ok)
eventsRecorder.assertSingle(JoinRoomEvents.ClearActionStates) eventsRecorder.assertSingle(JoinRoomEvents.ClearActionStates)
} }
@Test @Test
fun `clicking on cancel knock request emit the expected Event`() { fun `clicking on cancel knock request emit the expected Event`() = runAndroidComposeUiTest {
val eventsRecorder = EventsRecorder<JoinRoomEvents>() val eventsRecorder = EventsRecorder<JoinRoomEvents>()
rule.setJoinRoomView( setJoinRoomView(
aJoinRoomState( aJoinRoomState(
contentState = aLoadedContentState(joinAuthorisationStatus = JoinAuthorisationStatus.IsKnocked), contentState = aLoadedContentState(joinAuthorisationStatus = JoinAuthorisationStatus.IsKnocked),
eventSink = eventsRecorder, eventSink = eventsRecorder,
), ),
) )
rule.clickOn(R.string.screen_join_room_cancel_knock_action) clickOn(R.string.screen_join_room_cancel_knock_action)
eventsRecorder.assertSingle(JoinRoomEvents.CancelKnock(true)) eventsRecorder.assertSingle(JoinRoomEvents.CancelKnock(true))
} }
@Test @Test
fun `clicking on closing Cancel Knock error emits the expected Event`() { fun `clicking on closing Cancel Knock error emits the expected Event`() = runAndroidComposeUiTest {
val eventsRecorder = EventsRecorder<JoinRoomEvents>() val eventsRecorder = EventsRecorder<JoinRoomEvents>()
rule.setJoinRoomView( setJoinRoomView(
aJoinRoomState( aJoinRoomState(
contentState = aLoadedContentState(joinAuthorisationStatus = JoinAuthorisationStatus.IsKnocked), contentState = aLoadedContentState(joinAuthorisationStatus = JoinAuthorisationStatus.IsKnocked),
cancelKnockAction = AsyncAction.Failure(Exception("Error")), cancelKnockAction = AsyncAction.Failure(Exception("Error")),
eventSink = eventsRecorder, eventSink = eventsRecorder,
), ),
) )
rule.clickOn(CommonStrings.action_ok) clickOn(CommonStrings.action_ok)
eventsRecorder.assertSingle(JoinRoomEvents.ClearActionStates) eventsRecorder.assertSingle(JoinRoomEvents.ClearActionStates)
} }
@Test @Test
fun `clicking on closing Join error emits the expected Event`() { fun `clicking on closing Join error emits the expected Event`() = runAndroidComposeUiTest {
val eventsRecorder = EventsRecorder<JoinRoomEvents>() val eventsRecorder = EventsRecorder<JoinRoomEvents>()
rule.setJoinRoomView( setJoinRoomView(
aJoinRoomState( aJoinRoomState(
contentState = aLoadedContentState(joinAuthorisationStatus = JoinAuthorisationStatus.CanKnock), contentState = aLoadedContentState(joinAuthorisationStatus = JoinAuthorisationStatus.CanKnock),
joinAction = AsyncAction.Failure(Exception("Error")), joinAction = AsyncAction.Failure(Exception("Error")),
eventSink = eventsRecorder, eventSink = eventsRecorder,
), ),
) )
rule.clickOn(CommonStrings.action_ok) clickOn(CommonStrings.action_ok)
eventsRecorder.assertSingle(JoinRoomEvents.ClearActionStates) eventsRecorder.assertSingle(JoinRoomEvents.ClearActionStates)
} }
@Test @Test
fun `when joining room is successful, the expected callback is invoked`() { fun `when joining room is successful, the expected callback is invoked`() = runAndroidComposeUiTest {
val eventsRecorder = EventsRecorder<JoinRoomEvents>(expectEvents = false) val eventsRecorder = EventsRecorder<JoinRoomEvents>(expectEvents = false)
ensureCalledOnce { ensureCalledOnce {
rule.setJoinRoomView( setJoinRoomView(
aJoinRoomState( aJoinRoomState(
joinAction = AsyncAction.Success(Unit), joinAction = AsyncAction.Success(Unit),
eventSink = eventsRecorder, eventSink = eventsRecorder,
@ -146,53 +145,55 @@ class JoinRoomViewTest {
} }
@Test @Test
fun `clicking on Accept when JoinAuthorisationStatus is IsInvited emits the expected Event`() { fun `clicking on Accept when JoinAuthorisationStatus is IsInvited emits the expected Event`() = runAndroidComposeUiTest {
val eventsRecorder = EventsRecorder<JoinRoomEvents>() val eventsRecorder = EventsRecorder<JoinRoomEvents>()
val inviteData = anInviteData() val inviteData = anInviteData()
rule.setJoinRoomView( setJoinRoomView(
aJoinRoomState( aJoinRoomState(
contentState = aLoadedContentState(joinAuthorisationStatus = JoinAuthorisationStatus.IsInvited(inviteData, null)), contentState = aLoadedContentState(joinAuthorisationStatus = JoinAuthorisationStatus.IsInvited(inviteData, null)),
eventSink = eventsRecorder, eventSink = eventsRecorder,
), ),
) )
rule.clickOn(CommonStrings.action_accept) clickOn(CommonStrings.action_accept)
eventsRecorder.assertSingle(JoinRoomEvents.AcceptInvite(inviteData)) eventsRecorder.assertSingle(JoinRoomEvents.AcceptInvite(inviteData))
} }
@Test @Test
fun `clicking on Decline when JoinAuthorisationStatus is IsInvited emits the expected Event`() { fun `clicking on Decline when JoinAuthorisationStatus is IsInvited emits the expected Event`() = runAndroidComposeUiTest {
val eventsRecorder = EventsRecorder<JoinRoomEvents>() val eventsRecorder = EventsRecorder<JoinRoomEvents>()
val inviteData = anInviteData() val inviteData = anInviteData()
rule.setJoinRoomView( setJoinRoomView(
aJoinRoomState( aJoinRoomState(
contentState = aLoadedContentState(joinAuthorisationStatus = JoinAuthorisationStatus.IsInvited(inviteData, null)), contentState = aLoadedContentState(joinAuthorisationStatus = JoinAuthorisationStatus.IsInvited(inviteData, null)),
eventSink = eventsRecorder, eventSink = eventsRecorder,
), ),
) )
rule.clickOn(CommonStrings.action_decline) clickOn(CommonStrings.action_decline)
eventsRecorder.assertSingle(JoinRoomEvents.DeclineInvite(inviteData, false)) eventsRecorder.assertSingle(JoinRoomEvents.DeclineInvite(inviteData, false))
} }
@Test @Test
fun `clicking on Decline and block when JoinAuthorisationStatus is IsInvited and can report room, the expected callback is invoked`() { fun `clicking on Decline and block when JoinAuthorisationStatus is IsInvited and can report room, the expected callback is invoked`() {
val eventsRecorder = EventsRecorder<JoinRoomEvents>(expectEvents = false) runAndroidComposeUiTest {
val inviteData = anInviteData() val eventsRecorder = EventsRecorder<JoinRoomEvents>(expectEvents = false)
val joinRoomState = aJoinRoomState( val inviteData = anInviteData()
contentState = aLoadedContentState(joinAuthorisationStatus = JoinAuthorisationStatus.IsInvited(inviteData, aRoomMember().toInviteSender())), val joinRoomState = aJoinRoomState(
canReportRoom = true, contentState = aLoadedContentState(joinAuthorisationStatus = JoinAuthorisationStatus.IsInvited(inviteData, aRoomMember().toInviteSender())),
eventSink = eventsRecorder, canReportRoom = true,
) eventSink = eventsRecorder,
ensureCalledOnceWithParam(inviteData) {
rule.setJoinRoomView(
state = joinRoomState,
onDeclineInviteAndBlockUser = it,
) )
rule.clickOn(R.string.screen_join_room_decline_and_block_button_title) ensureCalledOnceWithParam(inviteData) {
setJoinRoomView(
state = joinRoomState,
onDeclineInviteAndBlockUser = it,
)
clickOn(R.string.screen_join_room_decline_and_block_button_title)
}
} }
} }
@Test @Test
fun `clicking on Decline and block when JoinAuthorisationStatus is IsInvited and cant report room, emits the expected Event`() { fun `clicking on Decline and block when JoinAuthorisationStatus is IsInvited and cant report room, emits the expected Event`() = runAndroidComposeUiTest {
val eventsRecorder = EventsRecorder<JoinRoomEvents>() val eventsRecorder = EventsRecorder<JoinRoomEvents>()
val inviteData = anInviteData() val inviteData = anInviteData()
val joinRoomState = aJoinRoomState( val joinRoomState = aJoinRoomState(
@ -200,29 +201,29 @@ class JoinRoomViewTest {
canReportRoom = false, canReportRoom = false,
eventSink = eventsRecorder, eventSink = eventsRecorder,
) )
rule.setJoinRoomView(state = joinRoomState) setJoinRoomView(state = joinRoomState)
rule.clickOn(R.string.screen_join_room_decline_and_block_button_title) clickOn(R.string.screen_join_room_decline_and_block_button_title)
eventsRecorder.assertSingle(JoinRoomEvents.DeclineInvite(inviteData, true)) eventsRecorder.assertSingle(JoinRoomEvents.DeclineInvite(inviteData, true))
} }
@Test @Test
fun `clicking on Retry when an error occurs emits the expected Event`() { fun `clicking on Retry when an error occurs emits the expected Event`() = runAndroidComposeUiTest {
val eventsRecorder = EventsRecorder<JoinRoomEvents>() val eventsRecorder = EventsRecorder<JoinRoomEvents>()
rule.setJoinRoomView( setJoinRoomView(
aJoinRoomState( aJoinRoomState(
contentState = aFailureContentState(), contentState = aFailureContentState(),
eventSink = eventsRecorder, eventSink = eventsRecorder,
), ),
) )
rule.clickOn(CommonStrings.action_retry) clickOn(CommonStrings.action_retry)
eventsRecorder.assertSingle(JoinRoomEvents.RetryFetchingContent) eventsRecorder.assertSingle(JoinRoomEvents.RetryFetchingContent)
} }
@Test @Test
fun `clicking on ok when user is unauthorized the expected callback`() { fun `clicking on ok when user is unauthorized the expected callback`() = runAndroidComposeUiTest {
val eventsRecorder = EventsRecorder<JoinRoomEvents>(expectEvents = false) val eventsRecorder = EventsRecorder<JoinRoomEvents>(expectEvents = false)
ensureCalledOnce { ensureCalledOnce {
rule.setJoinRoomView( setJoinRoomView(
aJoinRoomState( aJoinRoomState(
contentState = aLoadedContentState(), contentState = aLoadedContentState(),
joinAction = AsyncAction.Failure(JoinRoom.Failures.UnauthorizedJoin), joinAction = AsyncAction.Failure(JoinRoom.Failures.UnauthorizedJoin),
@ -230,25 +231,25 @@ class JoinRoomViewTest {
), ),
onBackClick = it onBackClick = it
) )
rule.clickOn(CommonStrings.action_ok) clickOn(CommonStrings.action_ok)
} }
} }
@Test @Test
fun `clicking on forget when user is banned invokes the expected callback`() { fun `clicking on forget when user is banned invokes the expected callback`() = runAndroidComposeUiTest {
val eventsRecorder = EventsRecorder<JoinRoomEvents>() val eventsRecorder = EventsRecorder<JoinRoomEvents>()
rule.setJoinRoomView( setJoinRoomView(
aJoinRoomState( aJoinRoomState(
contentState = aLoadedContentState(joinAuthorisationStatus = JoinAuthorisationStatus.IsBanned(null, null)), contentState = aLoadedContentState(joinAuthorisationStatus = JoinAuthorisationStatus.IsBanned(null, null)),
eventSink = eventsRecorder, eventSink = eventsRecorder,
), ),
) )
rule.clickOn(R.string.screen_join_room_forget_action) clickOn(R.string.screen_join_room_forget_action)
eventsRecorder.assertSingle(JoinRoomEvents.ForgetRoom) eventsRecorder.assertSingle(JoinRoomEvents.ForgetRoom)
} }
} }
private fun <R : TestRule> AndroidComposeTestRule<R, ComponentActivity>.setJoinRoomView( private fun AndroidComposeUiTest<ComponentActivity>.setJoinRoomView(
state: JoinRoomState, state: JoinRoomState,
onBackClick: () -> Unit = EnsureNeverCalled(), onBackClick: () -> Unit = EnsureNeverCalled(),
onJoinSuccess: () -> Unit = EnsureNeverCalled(), onJoinSuccess: () -> Unit = EnsureNeverCalled(),

View file

@ -6,13 +6,16 @@
* Please see LICENSE files in the repository root for full details. * Please see LICENSE files in the repository root for full details.
*/ */
@file:OptIn(ExperimentalTestApi::class)
package io.element.android.features.knockrequests.impl.banner package io.element.android.features.knockrequests.impl.banner
import androidx.activity.ComponentActivity import androidx.activity.ComponentActivity
import androidx.compose.ui.test.junit4.AndroidComposeTestRule import androidx.compose.ui.test.AndroidComposeUiTest
import androidx.compose.ui.test.junit4.createAndroidComposeRule import androidx.compose.ui.test.ExperimentalTestApi
import androidx.compose.ui.test.onNodeWithContentDescription import androidx.compose.ui.test.onNodeWithContentDescription
import androidx.compose.ui.test.performClick import androidx.compose.ui.test.performClick
import androidx.compose.ui.test.v2.runAndroidComposeUiTest
import androidx.test.ext.junit.runners.AndroidJUnit4 import androidx.test.ext.junit.runners.AndroidJUnit4
import io.element.android.features.knockrequests.impl.R import io.element.android.features.knockrequests.impl.R
import io.element.android.features.knockrequests.impl.data.aKnockRequestPresentable import io.element.android.features.knockrequests.impl.data.aKnockRequestPresentable
@ -21,35 +24,30 @@ import io.element.android.tests.testutils.EnsureNeverCalled
import io.element.android.tests.testutils.EventsRecorder import io.element.android.tests.testutils.EventsRecorder
import io.element.android.tests.testutils.clickOn import io.element.android.tests.testutils.clickOn
import io.element.android.tests.testutils.ensureCalledOnce import io.element.android.tests.testutils.ensureCalledOnce
import org.junit.Rule
import org.junit.Test import org.junit.Test
import org.junit.rules.TestRule
import org.junit.runner.RunWith import org.junit.runner.RunWith
@RunWith(AndroidJUnit4::class) @RunWith(AndroidJUnit4::class)
class KnockRequestsBannerViewTest { class KnockRequestsBannerViewTest {
@get:Rule
val rule = createAndroidComposeRule<ComponentActivity>()
@Test @Test
fun `clicking on view on single request invoke the expected callback`() { fun `clicking on view on single request invoke the expected callback`() = runAndroidComposeUiTest {
val eventsRecorder = EventsRecorder<KnockRequestsBannerEvents>(expectEvents = false) val eventsRecorder = EventsRecorder<KnockRequestsBannerEvents>(expectEvents = false)
ensureCalledOnce { ensureCalledOnce {
rule.setKnockRequestsBannerView( setKnockRequestsBannerView(
state = aKnockRequestsBannerState( state = aKnockRequestsBannerState(
eventSink = eventsRecorder, eventSink = eventsRecorder,
), ),
onViewRequestsClick = it onViewRequestsClick = it
) )
rule.clickOn(R.string.screen_room_single_knock_request_view_button_title) clickOn(R.string.screen_room_single_knock_request_view_button_title)
} }
} }
@Test @Test
fun `clicking on view all when multiple requests invoke the expected callback`() { fun `clicking on view all when multiple requests invoke the expected callback`() = runAndroidComposeUiTest {
val eventsRecorder = EventsRecorder<KnockRequestsBannerEvents>(expectEvents = false) val eventsRecorder = EventsRecorder<KnockRequestsBannerEvents>(expectEvents = false)
ensureCalledOnce { ensureCalledOnce {
rule.setKnockRequestsBannerView( setKnockRequestsBannerView(
state = aKnockRequestsBannerState( state = aKnockRequestsBannerState(
knockRequests = listOf( knockRequests = listOf(
aKnockRequestPresentable(displayName = "Alice"), aKnockRequestPresentable(displayName = "Alice"),
@ -60,37 +58,37 @@ class KnockRequestsBannerViewTest {
), ),
onViewRequestsClick = it onViewRequestsClick = it
) )
rule.clickOn(R.string.screen_room_multiple_knock_requests_view_all_button_title) clickOn(R.string.screen_room_multiple_knock_requests_view_all_button_title)
} }
} }
@Test @Test
fun `clicking on accept on a single request emit the expected event`() { fun `clicking on accept on a single request emit the expected event`() = runAndroidComposeUiTest {
val eventsRecorder = EventsRecorder<KnockRequestsBannerEvents>() val eventsRecorder = EventsRecorder<KnockRequestsBannerEvents>()
rule.setKnockRequestsBannerView( setKnockRequestsBannerView(
state = aKnockRequestsBannerState( state = aKnockRequestsBannerState(
eventSink = eventsRecorder, eventSink = eventsRecorder,
), ),
) )
rule.clickOn(CommonStrings.action_accept) clickOn(CommonStrings.action_accept)
eventsRecorder.assertSingle(KnockRequestsBannerEvents.AcceptSingleRequest) eventsRecorder.assertSingle(KnockRequestsBannerEvents.AcceptSingleRequest)
} }
@Test @Test
fun `clicking on dismiss emit the expected event`() { fun `clicking on dismiss emit the expected event`() = runAndroidComposeUiTest {
val eventsRecorder = EventsRecorder<KnockRequestsBannerEvents>() val eventsRecorder = EventsRecorder<KnockRequestsBannerEvents>()
rule.setKnockRequestsBannerView( setKnockRequestsBannerView(
state = aKnockRequestsBannerState( state = aKnockRequestsBannerState(
eventSink = eventsRecorder, eventSink = eventsRecorder,
), ),
) )
val close = rule.activity.getString(CommonStrings.action_close) val close = activity!!.getString(CommonStrings.action_close)
rule.onNodeWithContentDescription(close).performClick() onNodeWithContentDescription(close).performClick()
eventsRecorder.assertSingle(KnockRequestsBannerEvents.Dismiss) eventsRecorder.assertSingle(KnockRequestsBannerEvents.Dismiss)
} }
} }
private fun <R : TestRule> AndroidComposeTestRule<R, ComponentActivity>.setKnockRequestsBannerView( private fun AndroidComposeUiTest<ComponentActivity>.setKnockRequestsBannerView(
state: KnockRequestsBannerState, state: KnockRequestsBannerState,
onViewRequestsClick: () -> Unit = EnsureNeverCalled(), onViewRequestsClick: () -> Unit = EnsureNeverCalled(),
) { ) {

View file

@ -6,11 +6,14 @@
* Please see LICENSE files in the repository root for full details. * Please see LICENSE files in the repository root for full details.
*/ */
@file:OptIn(ExperimentalTestApi::class)
package io.element.android.features.knockrequests.impl.list package io.element.android.features.knockrequests.impl.list
import androidx.activity.ComponentActivity import androidx.activity.ComponentActivity
import androidx.compose.ui.test.junit4.AndroidComposeTestRule import androidx.compose.ui.test.AndroidComposeUiTest
import androidx.compose.ui.test.junit4.createAndroidComposeRule import androidx.compose.ui.test.ExperimentalTestApi
import androidx.compose.ui.test.v2.runAndroidComposeUiTest
import androidx.test.ext.junit.runners.AndroidJUnit4 import androidx.test.ext.junit.runners.AndroidJUnit4
import io.element.android.features.knockrequests.impl.R import io.element.android.features.knockrequests.impl.R
import io.element.android.features.knockrequests.impl.data.aKnockRequestPresentable import io.element.android.features.knockrequests.impl.data.aKnockRequestPresentable
@ -23,90 +26,86 @@ import io.element.android.tests.testutils.clickOn
import io.element.android.tests.testutils.ensureCalledOnce import io.element.android.tests.testutils.ensureCalledOnce
import io.element.android.tests.testutils.pressBack import io.element.android.tests.testutils.pressBack
import kotlinx.collections.immutable.persistentListOf import kotlinx.collections.immutable.persistentListOf
import org.junit.Rule
import org.junit.Test import org.junit.Test
import org.junit.rules.TestRule
import org.junit.runner.RunWith import org.junit.runner.RunWith
@RunWith(AndroidJUnit4::class) @RunWith(AndroidJUnit4::class)
class KnockRequestsListViewTest { class KnockRequestsListViewTest {
@get:Rule val rule = createAndroidComposeRule<ComponentActivity>()
@Test @Test
fun `clicking on back invoke the expected callback`() { fun `clicking on back invoke the expected callback`() = runAndroidComposeUiTest {
val eventsRecorder = EventsRecorder<KnockRequestsListEvents>(expectEvents = false) val eventsRecorder = EventsRecorder<KnockRequestsListEvents>(expectEvents = false)
ensureCalledOnce { ensureCalledOnce {
rule.setKnockRequestsListView( setKnockRequestsListView(
aKnockRequestsListState( aKnockRequestsListState(
eventSink = eventsRecorder, eventSink = eventsRecorder,
), ),
onBackClick = it onBackClick = it
) )
rule.pressBack() pressBack()
} }
} }
@Test @Test
fun `clicking on accept emit the expected event`() { fun `clicking on accept emit the expected event`() = runAndroidComposeUiTest {
val eventsRecorder = EventsRecorder<KnockRequestsListEvents>() val eventsRecorder = EventsRecorder<KnockRequestsListEvents>()
val knockRequest = aKnockRequestPresentable() val knockRequest = aKnockRequestPresentable()
rule.setKnockRequestsListView( setKnockRequestsListView(
aKnockRequestsListState( aKnockRequestsListState(
knockRequests = AsyncData.Success(persistentListOf(knockRequest)), knockRequests = AsyncData.Success(persistentListOf(knockRequest)),
eventSink = eventsRecorder, eventSink = eventsRecorder,
), ),
) )
rule.clickOn(CommonStrings.action_accept) clickOn(CommonStrings.action_accept)
eventsRecorder.assertSingle(KnockRequestsListEvents.Accept(knockRequest)) eventsRecorder.assertSingle(KnockRequestsListEvents.Accept(knockRequest))
} }
@Test @Test
fun `clicking on decline emit the expected event`() { fun `clicking on decline emit the expected event`() = runAndroidComposeUiTest {
val eventsRecorder = EventsRecorder<KnockRequestsListEvents>() val eventsRecorder = EventsRecorder<KnockRequestsListEvents>()
val knockRequest = aKnockRequestPresentable() val knockRequest = aKnockRequestPresentable()
rule.setKnockRequestsListView( setKnockRequestsListView(
aKnockRequestsListState( aKnockRequestsListState(
knockRequests = AsyncData.Success(persistentListOf(knockRequest)), knockRequests = AsyncData.Success(persistentListOf(knockRequest)),
eventSink = eventsRecorder, eventSink = eventsRecorder,
), ),
) )
rule.clickOn(CommonStrings.action_decline) clickOn(CommonStrings.action_decline)
eventsRecorder.assertSingle(KnockRequestsListEvents.Decline(knockRequest)) eventsRecorder.assertSingle(KnockRequestsListEvents.Decline(knockRequest))
} }
@Test @Test
fun `clicking on decline and ban emit the expected event`() { fun `clicking on decline and ban emit the expected event`() = runAndroidComposeUiTest {
val eventsRecorder = EventsRecorder<KnockRequestsListEvents>() val eventsRecorder = EventsRecorder<KnockRequestsListEvents>()
val knockRequest = aKnockRequestPresentable() val knockRequest = aKnockRequestPresentable()
rule.setKnockRequestsListView( setKnockRequestsListView(
aKnockRequestsListState( aKnockRequestsListState(
knockRequests = AsyncData.Success(persistentListOf(knockRequest)), knockRequests = AsyncData.Success(persistentListOf(knockRequest)),
eventSink = eventsRecorder, eventSink = eventsRecorder,
), ),
) )
rule.clickOn(R.string.screen_knock_requests_list_decline_and_ban_action_title) clickOn(R.string.screen_knock_requests_list_decline_and_ban_action_title)
eventsRecorder.assertSingle(KnockRequestsListEvents.DeclineAndBan(knockRequest)) eventsRecorder.assertSingle(KnockRequestsListEvents.DeclineAndBan(knockRequest))
} }
@Test @Test
fun `clicking on accept all emit the expected event`() { fun `clicking on accept all emit the expected event`() = runAndroidComposeUiTest {
val eventsRecorder = EventsRecorder<KnockRequestsListEvents>() val eventsRecorder = EventsRecorder<KnockRequestsListEvents>()
val knockRequests = persistentListOf(aKnockRequestPresentable(), aKnockRequestPresentable()) val knockRequests = persistentListOf(aKnockRequestPresentable(), aKnockRequestPresentable())
rule.setKnockRequestsListView( setKnockRequestsListView(
aKnockRequestsListState( aKnockRequestsListState(
knockRequests = AsyncData.Success(knockRequests), knockRequests = AsyncData.Success(knockRequests),
eventSink = eventsRecorder, eventSink = eventsRecorder,
), ),
) )
rule.clickOn(R.string.screen_knock_requests_list_accept_all_button_title) clickOn(R.string.screen_knock_requests_list_accept_all_button_title)
eventsRecorder.assertSingle(KnockRequestsListEvents.AcceptAll) eventsRecorder.assertSingle(KnockRequestsListEvents.AcceptAll)
} }
@Test @Test
fun `retry on async view retry emit the expected event`() { fun `retry on async view retry emit the expected event`() = runAndroidComposeUiTest {
val eventsRecorder = EventsRecorder<KnockRequestsListEvents>() val eventsRecorder = EventsRecorder<KnockRequestsListEvents>()
val knockRequests = persistentListOf(aKnockRequestPresentable(), aKnockRequestPresentable()) val knockRequests = persistentListOf(aKnockRequestPresentable(), aKnockRequestPresentable())
rule.setKnockRequestsListView( setKnockRequestsListView(
aKnockRequestsListState( aKnockRequestsListState(
knockRequests = AsyncData.Success(knockRequests), knockRequests = AsyncData.Success(knockRequests),
asyncAction = AsyncAction.Failure(RuntimeException("Failed to accept all")), asyncAction = AsyncAction.Failure(RuntimeException("Failed to accept all")),
@ -114,15 +113,15 @@ class KnockRequestsListViewTest {
eventSink = eventsRecorder, eventSink = eventsRecorder,
), ),
) )
rule.clickOn(CommonStrings.action_retry) clickOn(CommonStrings.action_retry)
eventsRecorder.assertSingle(KnockRequestsListEvents.RetryCurrentAction) eventsRecorder.assertSingle(KnockRequestsListEvents.RetryCurrentAction)
} }
@Test @Test
fun `canceling async view emit the expected event`() { fun `canceling async view emit the expected event`() = runAndroidComposeUiTest {
val eventsRecorder = EventsRecorder<KnockRequestsListEvents>() val eventsRecorder = EventsRecorder<KnockRequestsListEvents>()
val knockRequests = persistentListOf(aKnockRequestPresentable(), aKnockRequestPresentable()) val knockRequests = persistentListOf(aKnockRequestPresentable(), aKnockRequestPresentable())
rule.setKnockRequestsListView( setKnockRequestsListView(
aKnockRequestsListState( aKnockRequestsListState(
knockRequests = AsyncData.Success(knockRequests), knockRequests = AsyncData.Success(knockRequests),
asyncAction = AsyncAction.Failure(RuntimeException("Failed to accept all")), asyncAction = AsyncAction.Failure(RuntimeException("Failed to accept all")),
@ -130,15 +129,15 @@ class KnockRequestsListViewTest {
eventSink = eventsRecorder, eventSink = eventsRecorder,
), ),
) )
rule.clickOn(CommonStrings.action_cancel) clickOn(CommonStrings.action_cancel)
eventsRecorder.assertSingle(KnockRequestsListEvents.ResetCurrentAction) eventsRecorder.assertSingle(KnockRequestsListEvents.ResetCurrentAction)
} }
@Test @Test
fun `confirming async view emit the expected event`() { fun `confirming async view emit the expected event`() = runAndroidComposeUiTest {
val eventsRecorder = EventsRecorder<KnockRequestsListEvents>() val eventsRecorder = EventsRecorder<KnockRequestsListEvents>()
val knockRequests = persistentListOf(aKnockRequestPresentable(), aKnockRequestPresentable()) val knockRequests = persistentListOf(aKnockRequestPresentable(), aKnockRequestPresentable())
rule.setKnockRequestsListView( setKnockRequestsListView(
aKnockRequestsListState( aKnockRequestsListState(
knockRequests = AsyncData.Success(knockRequests), knockRequests = AsyncData.Success(knockRequests),
asyncAction = AsyncAction.ConfirmingNoParams, asyncAction = AsyncAction.ConfirmingNoParams,
@ -146,12 +145,12 @@ class KnockRequestsListViewTest {
eventSink = eventsRecorder, eventSink = eventsRecorder,
), ),
) )
rule.clickOn(R.string.screen_knock_requests_list_accept_all_alert_confirm_button_title) clickOn(R.string.screen_knock_requests_list_accept_all_alert_confirm_button_title)
eventsRecorder.assertSingle(KnockRequestsListEvents.ConfirmCurrentAction) eventsRecorder.assertSingle(KnockRequestsListEvents.ConfirmCurrentAction)
} }
} }
private fun <R : TestRule> AndroidComposeTestRule<R, ComponentActivity>.setKnockRequestsListView( private fun AndroidComposeUiTest<ComponentActivity>.setKnockRequestsListView(
state: KnockRequestsListState, state: KnockRequestsListState,
onBackClick: () -> Unit = EnsureNeverCalled(), onBackClick: () -> Unit = EnsureNeverCalled(),
) { ) {

View file

@ -151,10 +151,7 @@ class LinkNewDeviceFlowNode(
LinkMobileStep.Starting -> { LinkMobileStep.Starting -> {
// This step is not received at the moment, so do nothing // This step is not received at the moment, so do nothing
} }
LinkMobileStep.SyncingSecrets -> { LinkMobileStep.SyncingSecrets -> Unit
// LinkMobileStep.Done is not received at the moment, so consider that the flow is done here
callback.onDone()
}
is LinkMobileStep.WaitingForAuth -> { is LinkMobileStep.WaitingForAuth -> {
navigateToBrowser(linkMobileStep.verificationUri) navigateToBrowser(linkMobileStep.verificationUri)
} }

View file

@ -5,11 +5,14 @@
* Please see LICENSE files in the repository root for full details. * Please see LICENSE files in the repository root for full details.
*/ */
@file:OptIn(ExperimentalTestApi::class)
package io.element.android.features.linknewdevice.impl.screens.desktop package io.element.android.features.linknewdevice.impl.screens.desktop
import androidx.activity.ComponentActivity import androidx.activity.ComponentActivity
import androidx.compose.ui.test.junit4.AndroidComposeTestRule import androidx.compose.ui.test.AndroidComposeUiTest
import androidx.compose.ui.test.junit4.createAndroidComposeRule import androidx.compose.ui.test.ExperimentalTestApi
import androidx.compose.ui.test.v2.runAndroidComposeUiTest
import androidx.test.ext.junit.runners.AndroidJUnit4 import androidx.test.ext.junit.runners.AndroidJUnit4
import io.element.android.features.linknewdevice.impl.R import io.element.android.features.linknewdevice.impl.R
import io.element.android.tests.testutils.EnsureNeverCalled import io.element.android.tests.testutils.EnsureNeverCalled
@ -18,42 +21,37 @@ import io.element.android.tests.testutils.clickOn
import io.element.android.tests.testutils.ensureCalledOnce import io.element.android.tests.testutils.ensureCalledOnce
import io.element.android.tests.testutils.pressBack import io.element.android.tests.testutils.pressBack
import io.element.android.tests.testutils.pressBackKey import io.element.android.tests.testutils.pressBackKey
import org.junit.Rule
import org.junit.Test import org.junit.Test
import org.junit.rules.TestRule
import org.junit.runner.RunWith import org.junit.runner.RunWith
@RunWith(AndroidJUnit4::class) @RunWith(AndroidJUnit4::class)
class DesktopNoticeViewTest { class DesktopNoticeViewTest {
@get:Rule
val rule = createAndroidComposeRule<ComponentActivity>()
@Test @Test
fun `on back pressed - calls the expected callback`() { fun `on back pressed - calls the expected callback`() = runAndroidComposeUiTest {
ensureCalledOnce { callback -> ensureCalledOnce { callback ->
rule.setView( setView(
state = aDesktopNoticeState(), state = aDesktopNoticeState(),
onBackClicked = callback, onBackClicked = callback,
) )
rule.pressBackKey() pressBackKey()
} }
} }
@Test @Test
fun `on back button clicked - calls the expected callback`() { fun `on back button clicked - calls the expected callback`() = runAndroidComposeUiTest {
ensureCalledOnce { callback -> ensureCalledOnce { callback ->
rule.setView( setView(
state = aDesktopNoticeState(), state = aDesktopNoticeState(),
onBackClicked = callback, onBackClicked = callback,
) )
rule.pressBack() pressBack()
} }
} }
@Test @Test
fun `when can continue - calls the expected callback`() { fun `when can continue - calls the expected callback`() = runAndroidComposeUiTest {
ensureCalledOnce { callback -> ensureCalledOnce { callback ->
rule.setView( setView(
state = aDesktopNoticeState(canContinue = true), state = aDesktopNoticeState(canContinue = true),
onReadyToScanClick = callback, onReadyToScanClick = callback,
) )
@ -61,16 +59,16 @@ class DesktopNoticeViewTest {
} }
@Test @Test
fun `on submit button clicked - emits the Continue event`() { fun `on submit button clicked - emits the Continue event`() = runAndroidComposeUiTest {
val eventRecorder = EventsRecorder<DesktopNoticeEvent>() val eventRecorder = EventsRecorder<DesktopNoticeEvent>()
rule.setView( setView(
state = aDesktopNoticeState(eventSink = eventRecorder), state = aDesktopNoticeState(eventSink = eventRecorder),
) )
rule.clickOn(R.string.screen_link_new_device_desktop_submit) clickOn(R.string.screen_link_new_device_desktop_submit)
eventRecorder.assertSingle(DesktopNoticeEvent.Continue) eventRecorder.assertSingle(DesktopNoticeEvent.Continue)
} }
private fun <R : TestRule> AndroidComposeTestRule<R, ComponentActivity>.setView( private fun AndroidComposeUiTest<ComponentActivity>.setView(
state: DesktopNoticeState, state: DesktopNoticeState,
onBackClicked: () -> Unit = EnsureNeverCalled(), onBackClicked: () -> Unit = EnsureNeverCalled(),
onReadyToScanClick: () -> Unit = EnsureNeverCalled(), onReadyToScanClick: () -> Unit = EnsureNeverCalled(),

View file

@ -5,58 +5,56 @@
* Please see LICENSE files in the repository root for full details. * Please see LICENSE files in the repository root for full details.
*/ */
@file:OptIn(ExperimentalTestApi::class)
package io.element.android.features.linknewdevice.impl.screens.error package io.element.android.features.linknewdevice.impl.screens.error
import androidx.activity.ComponentActivity import androidx.activity.ComponentActivity
import androidx.compose.ui.test.junit4.AndroidComposeTestRule import androidx.compose.ui.test.AndroidComposeUiTest
import androidx.compose.ui.test.junit4.createAndroidComposeRule import androidx.compose.ui.test.ExperimentalTestApi
import androidx.compose.ui.test.v2.runAndroidComposeUiTest
import androidx.test.ext.junit.runners.AndroidJUnit4 import androidx.test.ext.junit.runners.AndroidJUnit4
import io.element.android.libraries.ui.strings.CommonStrings import io.element.android.libraries.ui.strings.CommonStrings
import io.element.android.tests.testutils.EnsureNeverCalled import io.element.android.tests.testutils.EnsureNeverCalled
import io.element.android.tests.testutils.clickOn import io.element.android.tests.testutils.clickOn
import io.element.android.tests.testutils.ensureCalledOnce import io.element.android.tests.testutils.ensureCalledOnce
import io.element.android.tests.testutils.pressBackKey import io.element.android.tests.testutils.pressBackKey
import org.junit.Rule
import org.junit.Test import org.junit.Test
import org.junit.rules.TestRule
import org.junit.runner.RunWith import org.junit.runner.RunWith
@RunWith(AndroidJUnit4::class) @RunWith(AndroidJUnit4::class)
class ErrorViewTest { class ErrorViewTest {
@get:Rule
val rule = createAndroidComposeRule<ComponentActivity>()
@Test @Test
fun `on back pressed - calls the onCancel callback`() { fun `on back pressed - calls the onCancel callback`() = runAndroidComposeUiTest {
ensureCalledOnce { callback -> ensureCalledOnce { callback ->
rule.setErrorView( setErrorView(
onCancel = callback, onCancel = callback,
) )
rule.pressBackKey() pressBackKey()
} }
} }
@Test @Test
fun `on try again button clicked - calls the expected callback`() { fun `on try again button clicked - calls the expected callback`() = runAndroidComposeUiTest {
ensureCalledOnce { callback -> ensureCalledOnce { callback ->
rule.setErrorView( setErrorView(
onRetry = callback onRetry = callback
) )
rule.clickOn(CommonStrings.action_try_again) clickOn(CommonStrings.action_try_again)
} }
} }
@Test @Test
fun `on cancel button clicked - calls the expected callback`() { fun `on cancel button clicked - calls the expected callback`() = runAndroidComposeUiTest {
ensureCalledOnce { callback -> ensureCalledOnce { callback ->
rule.setErrorView( setErrorView(
onCancel = callback onCancel = callback
) )
rule.clickOn(CommonStrings.action_cancel) clickOn(CommonStrings.action_cancel)
} }
} }
private fun <R : TestRule> AndroidComposeTestRule<R, ComponentActivity>.setErrorView( private fun AndroidComposeUiTest<ComponentActivity>.setErrorView(
onRetry: () -> Unit = EnsureNeverCalled(), onRetry: () -> Unit = EnsureNeverCalled(),
onCancel: () -> Unit = EnsureNeverCalled(), onCancel: () -> Unit = EnsureNeverCalled(),
errorScreenType: ErrorScreenType = ErrorScreenType.UnknownError, errorScreenType: ErrorScreenType = ErrorScreenType.UnknownError,

View file

@ -5,13 +5,16 @@
* Please see LICENSE files in the repository root for full details. * Please see LICENSE files in the repository root for full details.
*/ */
@file:OptIn(ExperimentalTestApi::class)
package io.element.android.features.linknewdevice.impl.screens.number package io.element.android.features.linknewdevice.impl.screens.number
import androidx.activity.ComponentActivity import androidx.activity.ComponentActivity
import androidx.compose.ui.test.AndroidComposeUiTest
import androidx.compose.ui.test.ExperimentalTestApi
import androidx.compose.ui.test.assertIsNotEnabled import androidx.compose.ui.test.assertIsNotEnabled
import androidx.compose.ui.test.junit4.AndroidComposeTestRule
import androidx.compose.ui.test.junit4.createAndroidComposeRule
import androidx.compose.ui.test.onNodeWithText import androidx.compose.ui.test.onNodeWithText
import androidx.compose.ui.test.v2.runAndroidComposeUiTest
import androidx.test.ext.junit.runners.AndroidJUnit4 import androidx.test.ext.junit.runners.AndroidJUnit4
import io.element.android.libraries.ui.strings.CommonStrings import io.element.android.libraries.ui.strings.CommonStrings
import io.element.android.tests.testutils.EnsureNeverCalled import io.element.android.tests.testutils.EnsureNeverCalled
@ -20,65 +23,60 @@ import io.element.android.tests.testutils.clickOn
import io.element.android.tests.testutils.ensureCalledOnce import io.element.android.tests.testutils.ensureCalledOnce
import io.element.android.tests.testutils.pressBack import io.element.android.tests.testutils.pressBack
import io.element.android.tests.testutils.pressBackKey import io.element.android.tests.testutils.pressBackKey
import org.junit.Rule
import org.junit.Test import org.junit.Test
import org.junit.rules.TestRule
import org.junit.runner.RunWith import org.junit.runner.RunWith
@RunWith(AndroidJUnit4::class) @RunWith(AndroidJUnit4::class)
class EnterNumberViewTest { class EnterNumberViewTest {
@get:Rule
val rule = createAndroidComposeRule<ComponentActivity>()
@Test @Test
fun `on back pressed - calls the expected callback`() { fun `on back pressed - calls the expected callback`() = runAndroidComposeUiTest {
ensureCalledOnce { callback -> ensureCalledOnce { callback ->
rule.setView( setView(
state = aEnterNumberState(), state = aEnterNumberState(),
onBackClicked = callback, onBackClicked = callback,
) )
rule.pressBackKey() pressBackKey()
} }
} }
@Test @Test
fun `on back button clicked - calls the expected callback`() { fun `on back button clicked - calls the expected callback`() = runAndroidComposeUiTest {
ensureCalledOnce { callback -> ensureCalledOnce { callback ->
rule.setView( setView(
state = aEnterNumberState(), state = aEnterNumberState(),
onBackClicked = callback, onBackClicked = callback,
) )
rule.pressBack() pressBack()
} }
} }
@Test @Test
fun `on continue button clicked - emits the Continue event`() { fun `on continue button clicked - emits the Continue event`() = runAndroidComposeUiTest {
val eventRecorder = EventsRecorder<EnterNumberEvent>() val eventRecorder = EventsRecorder<EnterNumberEvent>()
rule.setView( setView(
state = aEnterNumberState( state = aEnterNumberState(
number = "12", number = "12",
eventSink = eventRecorder, eventSink = eventRecorder,
), ),
) )
rule.clickOn(CommonStrings.action_continue) clickOn(CommonStrings.action_continue)
eventRecorder.assertSingle(EnterNumberEvent.Continue) eventRecorder.assertSingle(EnterNumberEvent.Continue)
} }
@Test @Test
fun `when the number is not complete, continue button is disabled`() { fun `when the number is not complete, continue button is disabled`() = runAndroidComposeUiTest {
val eventRecorder = EventsRecorder<EnterNumberEvent>(expectEvents = false) val eventRecorder = EventsRecorder<EnterNumberEvent>(expectEvents = false)
rule.setView( setView(
state = aEnterNumberState( state = aEnterNumberState(
number = "1", number = "1",
eventSink = eventRecorder, eventSink = eventRecorder,
), ),
) )
val continueStr = rule.activity.getString(CommonStrings.action_continue) val continueStr = activity!!.getString(CommonStrings.action_continue)
rule.onNodeWithText(continueStr).assertIsNotEnabled() onNodeWithText(continueStr).assertIsNotEnabled()
} }
private fun <R : TestRule> AndroidComposeTestRule<R, ComponentActivity>.setView( private fun AndroidComposeUiTest<ComponentActivity>.setView(
state: EnterNumberState, state: EnterNumberState,
onBackClicked: () -> Unit = EnsureNeverCalled(), onBackClicked: () -> Unit = EnsureNeverCalled(),
) { ) {

View file

@ -5,36 +5,34 @@
* Please see LICENSE files in the repository root for full details. * Please see LICENSE files in the repository root for full details.
*/ */
@file:OptIn(ExperimentalTestApi::class)
package io.element.android.features.linknewdevice.impl.screens.qrcode package io.element.android.features.linknewdevice.impl.screens.qrcode
import androidx.activity.ComponentActivity import androidx.activity.ComponentActivity
import androidx.compose.ui.test.junit4.AndroidComposeTestRule import androidx.compose.ui.test.AndroidComposeUiTest
import androidx.compose.ui.test.junit4.createAndroidComposeRule import androidx.compose.ui.test.ExperimentalTestApi
import androidx.compose.ui.test.v2.runAndroidComposeUiTest
import androidx.test.ext.junit.runners.AndroidJUnit4 import androidx.test.ext.junit.runners.AndroidJUnit4
import io.element.android.tests.testutils.EnsureNeverCalled import io.element.android.tests.testutils.EnsureNeverCalled
import io.element.android.tests.testutils.ensureCalledOnce import io.element.android.tests.testutils.ensureCalledOnce
import io.element.android.tests.testutils.pressBackKey import io.element.android.tests.testutils.pressBackKey
import org.junit.Rule
import org.junit.Test import org.junit.Test
import org.junit.rules.TestRule
import org.junit.runner.RunWith import org.junit.runner.RunWith
@RunWith(AndroidJUnit4::class) @RunWith(AndroidJUnit4::class)
class ShowQrCodeViewTest { class ShowQrCodeViewTest {
@get:Rule
val rule = createAndroidComposeRule<ComponentActivity>()
@Test @Test
fun `on back pressed - calls the expected callback`() { fun `on back pressed - calls the expected callback`() = runAndroidComposeUiTest {
ensureCalledOnce { callback -> ensureCalledOnce { callback ->
rule.setView( setView(
onBackClick = callback onBackClick = callback
) )
rule.pressBackKey() pressBackKey()
} }
} }
private fun <R : TestRule> AndroidComposeTestRule<R, ComponentActivity>.setView( private fun AndroidComposeUiTest<ComponentActivity>.setView(
onBackClick: () -> Unit = EnsureNeverCalled(), onBackClick: () -> Unit = EnsureNeverCalled(),
) { ) {
setContent { setContent {

View file

@ -5,11 +5,14 @@
* Please see LICENSE files in the repository root for full details. * Please see LICENSE files in the repository root for full details.
*/ */
@file:OptIn(ExperimentalTestApi::class)
package io.element.android.features.linknewdevice.impl.screens.root package io.element.android.features.linknewdevice.impl.screens.root
import androidx.activity.ComponentActivity import androidx.activity.ComponentActivity
import androidx.compose.ui.test.junit4.AndroidComposeTestRule import androidx.compose.ui.test.AndroidComposeUiTest
import androidx.compose.ui.test.junit4.createAndroidComposeRule import androidx.compose.ui.test.ExperimentalTestApi
import androidx.compose.ui.test.v2.runAndroidComposeUiTest
import androidx.test.ext.junit.runners.AndroidJUnit4 import androidx.test.ext.junit.runners.AndroidJUnit4
import io.element.android.features.linknewdevice.impl.R import io.element.android.features.linknewdevice.impl.R
import io.element.android.libraries.architecture.AsyncData import io.element.android.libraries.architecture.AsyncData
@ -19,74 +22,69 @@ import io.element.android.tests.testutils.EventsRecorder
import io.element.android.tests.testutils.clickOn import io.element.android.tests.testutils.clickOn
import io.element.android.tests.testutils.ensureCalledOnce import io.element.android.tests.testutils.ensureCalledOnce
import io.element.android.tests.testutils.pressBackKey import io.element.android.tests.testutils.pressBackKey
import org.junit.Rule
import org.junit.Test import org.junit.Test
import org.junit.rules.TestRule
import org.junit.runner.RunWith import org.junit.runner.RunWith
@RunWith(AndroidJUnit4::class) @RunWith(AndroidJUnit4::class)
class LinkNewDeviceRootViewTest { class LinkNewDeviceRootViewTest {
@get:Rule
val rule = createAndroidComposeRule<ComponentActivity>()
@Test @Test
fun `on back pressed - calls the onRetry callback`() { fun `on back pressed - calls the onRetry callback`() = runAndroidComposeUiTest {
val eventRecorder = EventsRecorder<LinkNewDeviceRootEvent>(expectEvents = false) val eventRecorder = EventsRecorder<LinkNewDeviceRootEvent>(expectEvents = false)
ensureCalledOnce { callback -> ensureCalledOnce { callback ->
rule.setLinkNewDeviceRootView( setLinkNewDeviceRootView(
state = aLinkNewDeviceRootState( state = aLinkNewDeviceRootState(
eventSink = eventRecorder, eventSink = eventRecorder,
), ),
onBackClick = callback onBackClick = callback
) )
rule.pressBackKey() pressBackKey()
} }
} }
@Test @Test
fun `link desktop button clicked - calls the expected callback`() { fun `link desktop button clicked - calls the expected callback`() = runAndroidComposeUiTest {
val eventRecorder = EventsRecorder<LinkNewDeviceRootEvent>(expectEvents = false) val eventRecorder = EventsRecorder<LinkNewDeviceRootEvent>(expectEvents = false)
ensureCalledOnce { callback -> ensureCalledOnce { callback ->
rule.setLinkNewDeviceRootView( setLinkNewDeviceRootView(
state = aLinkNewDeviceRootState( state = aLinkNewDeviceRootState(
isSupported = AsyncData.Success(true), isSupported = AsyncData.Success(true),
eventSink = eventRecorder, eventSink = eventRecorder,
), ),
onLinkDesktopDeviceClick = callback, onLinkDesktopDeviceClick = callback,
) )
rule.clickOn(R.string.screen_link_new_device_root_desktop_computer) clickOn(R.string.screen_link_new_device_root_desktop_computer)
} }
} }
@Test @Test
fun `link mobile button clicked - emits the expected event`() { fun `link mobile button clicked - emits the expected event`() = runAndroidComposeUiTest {
val eventRecorder = EventsRecorder<LinkNewDeviceRootEvent>() val eventRecorder = EventsRecorder<LinkNewDeviceRootEvent>()
rule.setLinkNewDeviceRootView( setLinkNewDeviceRootView(
state = aLinkNewDeviceRootState( state = aLinkNewDeviceRootState(
isSupported = AsyncData.Success(true), isSupported = AsyncData.Success(true),
eventSink = eventRecorder, eventSink = eventRecorder,
) )
) )
rule.clickOn(R.string.screen_link_new_device_root_mobile_device) clickOn(R.string.screen_link_new_device_root_mobile_device)
eventRecorder.assertSingle(LinkNewDeviceRootEvent.LinkMobileDevice) eventRecorder.assertSingle(LinkNewDeviceRootEvent.LinkMobileDevice)
} }
@Test @Test
fun `not supported - dismiss click - invokes the expected callback`() { fun `not supported - dismiss click - invokes the expected callback`() = runAndroidComposeUiTest {
val eventRecorder = EventsRecorder<LinkNewDeviceRootEvent>(expectEvents = false) val eventRecorder = EventsRecorder<LinkNewDeviceRootEvent>(expectEvents = false)
ensureCalledOnce { callback -> ensureCalledOnce { callback ->
rule.setLinkNewDeviceRootView( setLinkNewDeviceRootView(
state = aLinkNewDeviceRootState( state = aLinkNewDeviceRootState(
isSupported = AsyncData.Success(false), isSupported = AsyncData.Success(false),
eventSink = eventRecorder, eventSink = eventRecorder,
), ),
onBackClick = callback, onBackClick = callback,
) )
rule.clickOn(CommonStrings.action_dismiss) clickOn(CommonStrings.action_dismiss)
} }
} }
private fun <R : TestRule> AndroidComposeTestRule<R, ComponentActivity>.setLinkNewDeviceRootView( private fun AndroidComposeUiTest<ComponentActivity>.setLinkNewDeviceRootView(
state: LinkNewDeviceRootState = aLinkNewDeviceRootState(), state: LinkNewDeviceRootState = aLinkNewDeviceRootState(),
onBackClick: () -> Unit = EnsureNeverCalled(), onBackClick: () -> Unit = EnsureNeverCalled(),
onLinkDesktopDeviceClick: () -> Unit = EnsureNeverCalled(), onLinkDesktopDeviceClick: () -> Unit = EnsureNeverCalled(),

View file

@ -5,11 +5,14 @@
* Please see LICENSE files in the repository root for full details. * Please see LICENSE files in the repository root for full details.
*/ */
@file:OptIn(ExperimentalTestApi::class)
package io.element.android.features.linknewdevice.impl.screens.scan package io.element.android.features.linknewdevice.impl.screens.scan
import androidx.activity.ComponentActivity import androidx.activity.ComponentActivity
import androidx.compose.ui.test.junit4.AndroidComposeTestRule import androidx.compose.ui.test.AndroidComposeUiTest
import androidx.compose.ui.test.junit4.createAndroidComposeRule import androidx.compose.ui.test.ExperimentalTestApi
import androidx.compose.ui.test.v2.runAndroidComposeUiTest
import androidx.test.ext.junit.runners.AndroidJUnit4 import androidx.test.ext.junit.runners.AndroidJUnit4
import io.element.android.libraries.architecture.AsyncAction import io.element.android.libraries.architecture.AsyncAction
import io.element.android.libraries.matrix.test.AN_EXCEPTION import io.element.android.libraries.matrix.test.AN_EXCEPTION
@ -19,44 +22,39 @@ import io.element.android.tests.testutils.EventsRecorder
import io.element.android.tests.testutils.clickOn import io.element.android.tests.testutils.clickOn
import io.element.android.tests.testutils.ensureCalledOnce import io.element.android.tests.testutils.ensureCalledOnce
import io.element.android.tests.testutils.pressBackKey import io.element.android.tests.testutils.pressBackKey
import org.junit.Rule
import org.junit.Test import org.junit.Test
import org.junit.rules.TestRule
import org.junit.runner.RunWith import org.junit.runner.RunWith
@RunWith(AndroidJUnit4::class) @RunWith(AndroidJUnit4::class)
class ScanQrCodeViewTest { class ScanQrCodeViewTest {
@get:Rule
val rule = createAndroidComposeRule<ComponentActivity>()
@Test @Test
fun `on back pressed - calls the expected callback`() { fun `on back pressed - calls the expected callback`() = runAndroidComposeUiTest {
val eventRecorder = EventsRecorder<ScanQrCodeEvent>(expectEvents = false) val eventRecorder = EventsRecorder<ScanQrCodeEvent>(expectEvents = false)
ensureCalledOnce { callback -> ensureCalledOnce { callback ->
rule.setView( setView(
state = aScanQrCodeState( state = aScanQrCodeState(
eventSink = eventRecorder, eventSink = eventRecorder,
), ),
onBackClick = callback onBackClick = callback
) )
rule.pressBackKey() pressBackKey()
} }
} }
@Test @Test
fun `try again button clicked - emits the expected event`() { fun `try again button clicked - emits the expected event`() = runAndroidComposeUiTest {
val eventRecorder = EventsRecorder<ScanQrCodeEvent>() val eventRecorder = EventsRecorder<ScanQrCodeEvent>()
rule.setView( setView(
state = aScanQrCodeState( state = aScanQrCodeState(
scanAction = AsyncAction.Failure(AN_EXCEPTION), scanAction = AsyncAction.Failure(AN_EXCEPTION),
eventSink = eventRecorder, eventSink = eventRecorder,
) )
) )
rule.clickOn(CommonStrings.action_try_again) clickOn(CommonStrings.action_try_again)
eventRecorder.assertSingle(ScanQrCodeEvent.TryAgain) eventRecorder.assertSingle(ScanQrCodeEvent.TryAgain)
} }
private fun <R : TestRule> AndroidComposeTestRule<R, ComponentActivity>.setView( private fun AndroidComposeUiTest<ComponentActivity>.setView(
state: ScanQrCodeState = aScanQrCodeState(), state: ScanQrCodeState = aScanQrCodeState(),
onBackClick: () -> Unit = EnsureNeverCalled(), onBackClick: () -> Unit = EnsureNeverCalled(),
) { ) {

View file

@ -5,15 +5,18 @@
* Please see LICENSE files in the repository root for full details. * Please see LICENSE files in the repository root for full details.
*/ */
@file:OptIn(ExperimentalTestApi::class)
package io.element.android.features.location.impl.share package io.element.android.features.location.impl.share
import androidx.activity.ComponentActivity import androidx.activity.ComponentActivity
import androidx.compose.runtime.CompositionLocalProvider import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.ui.platform.LocalInspectionMode import androidx.compose.ui.platform.LocalInspectionMode
import androidx.compose.ui.test.junit4.AndroidComposeTestRule import androidx.compose.ui.test.AndroidComposeUiTest
import androidx.compose.ui.test.junit4.createAndroidComposeRule import androidx.compose.ui.test.ExperimentalTestApi
import androidx.compose.ui.test.onNodeWithTag import androidx.compose.ui.test.onNodeWithTag
import androidx.compose.ui.test.performClick import androidx.compose.ui.test.performClick
import androidx.compose.ui.test.v2.runAndroidComposeUiTest
import androidx.test.ext.junit.runners.AndroidJUnit4 import androidx.test.ext.junit.runners.AndroidJUnit4
import io.element.android.features.location.impl.common.ui.LocationConstraintsDialogState import io.element.android.features.location.impl.common.ui.LocationConstraintsDialogState
import io.element.android.libraries.testtags.TestTags import io.element.android.libraries.testtags.TestTags
@ -23,102 +26,98 @@ import io.element.android.tests.testutils.EventsRecorder
import io.element.android.tests.testutils.clickOn import io.element.android.tests.testutils.clickOn
import io.element.android.tests.testutils.ensureCalledOnce import io.element.android.tests.testutils.ensureCalledOnce
import io.element.android.tests.testutils.pressBack import io.element.android.tests.testutils.pressBack
import org.junit.Rule
import org.junit.Test import org.junit.Test
import org.junit.rules.TestRule
import org.junit.runner.RunWith import org.junit.runner.RunWith
@RunWith(AndroidJUnit4::class) @RunWith(AndroidJUnit4::class)
class ShareLocationViewTest { class ShareLocationViewTest {
@get:Rule val rule = createAndroidComposeRule<ComponentActivity>()
@Test @Test
fun `test back action`() { fun `test back action`() = runAndroidComposeUiTest {
val eventsRecorder = EventsRecorder<ShareLocationEvent>(expectEvents = false) val eventsRecorder = EventsRecorder<ShareLocationEvent>(expectEvents = false)
ensureCalledOnce { callback -> ensureCalledOnce { callback ->
rule.setShareLocationView( setShareLocationView(
state = aShareLocationState( state = aShareLocationState(
eventSink = eventsRecorder eventSink = eventsRecorder
), ),
navigateUp = callback, navigateUp = callback,
) )
rule.pressBack() pressBack()
} }
} }
@Test @Test
fun `test fab click`() { fun `test fab click`() = runAndroidComposeUiTest {
val eventsRecorder = EventsRecorder<ShareLocationEvent>() val eventsRecorder = EventsRecorder<ShareLocationEvent>()
rule.setShareLocationView( setShareLocationView(
aShareLocationState( aShareLocationState(
eventSink = eventsRecorder eventSink = eventsRecorder
), ),
navigateUp = EnsureNeverCalled(), navigateUp = EnsureNeverCalled(),
) )
rule.onNodeWithTag(TestTags.floatingActionButton.value).performClick() onNodeWithTag(TestTags.floatingActionButton.value).performClick()
eventsRecorder.assertSingle(ShareLocationEvent.StartTrackingUserLocation) eventsRecorder.assertSingle(ShareLocationEvent.StartTrackingUserLocation)
} }
@Test @Test
fun `when permission denied is displayed user can open the settings`() { fun `when permission denied is displayed user can open the settings`() = runAndroidComposeUiTest {
val eventsRecorder = EventsRecorder<ShareLocationEvent>() val eventsRecorder = EventsRecorder<ShareLocationEvent>()
rule.setShareLocationView( setShareLocationView(
aShareLocationState( aShareLocationState(
dialogState = ShareLocationState.Dialog.Constraints(LocationConstraintsDialogState.PermissionDenied), dialogState = ShareLocationState.Dialog.Constraints(LocationConstraintsDialogState.PermissionDenied),
eventSink = eventsRecorder eventSink = eventsRecorder
), ),
navigateUp = EnsureNeverCalled(), navigateUp = EnsureNeverCalled(),
) )
rule.clickOn(CommonStrings.action_continue) clickOn(CommonStrings.action_continue)
eventsRecorder.assertSingle(ShareLocationEvent.OpenAppSettings) eventsRecorder.assertSingle(ShareLocationEvent.OpenAppSettings)
} }
@Test @Test
fun `when permission denied is displayed user can close the dialog`() { fun `when permission denied is displayed user can close the dialog`() = runAndroidComposeUiTest {
val eventsRecorder = EventsRecorder<ShareLocationEvent>() val eventsRecorder = EventsRecorder<ShareLocationEvent>()
rule.setShareLocationView( setShareLocationView(
aShareLocationState( aShareLocationState(
dialogState = ShareLocationState.Dialog.Constraints(LocationConstraintsDialogState.PermissionDenied), dialogState = ShareLocationState.Dialog.Constraints(LocationConstraintsDialogState.PermissionDenied),
eventSink = eventsRecorder eventSink = eventsRecorder
), ),
navigateUp = EnsureNeverCalled(), navigateUp = EnsureNeverCalled(),
) )
rule.clickOn(CommonStrings.action_cancel) clickOn(CommonStrings.action_cancel)
eventsRecorder.assertSingle(ShareLocationEvent.DismissDialog) eventsRecorder.assertSingle(ShareLocationEvent.DismissDialog)
} }
@Test @Test
fun `when permission rationale is displayed user can request permissions`() { fun `when permission rationale is displayed user can request permissions`() = runAndroidComposeUiTest {
val eventsRecorder = EventsRecorder<ShareLocationEvent>() val eventsRecorder = EventsRecorder<ShareLocationEvent>()
rule.setShareLocationView( setShareLocationView(
aShareLocationState( aShareLocationState(
dialogState = ShareLocationState.Dialog.Constraints(LocationConstraintsDialogState.PermissionRationale), dialogState = ShareLocationState.Dialog.Constraints(LocationConstraintsDialogState.PermissionRationale),
eventSink = eventsRecorder eventSink = eventsRecorder
), ),
navigateUp = EnsureNeverCalled(), navigateUp = EnsureNeverCalled(),
) )
rule.clickOn(CommonStrings.action_continue) clickOn(CommonStrings.action_continue)
eventsRecorder.assertSingle(ShareLocationEvent.RequestPermissions) eventsRecorder.assertSingle(ShareLocationEvent.RequestPermissions)
} }
@Test @Test
fun `when permission rationale is displayed user can close the dialog`() { fun `when permission rationale is displayed user can close the dialog`() = runAndroidComposeUiTest {
val eventsRecorder = EventsRecorder<ShareLocationEvent>() val eventsRecorder = EventsRecorder<ShareLocationEvent>()
rule.setShareLocationView( setShareLocationView(
aShareLocationState( aShareLocationState(
dialogState = ShareLocationState.Dialog.Constraints(LocationConstraintsDialogState.PermissionRationale), dialogState = ShareLocationState.Dialog.Constraints(LocationConstraintsDialogState.PermissionRationale),
eventSink = eventsRecorder eventSink = eventsRecorder
), ),
navigateUp = EnsureNeverCalled(), navigateUp = EnsureNeverCalled(),
) )
rule.clickOn(CommonStrings.action_cancel) clickOn(CommonStrings.action_cancel)
eventsRecorder.assertSingle(ShareLocationEvent.DismissDialog) eventsRecorder.assertSingle(ShareLocationEvent.DismissDialog)
} }
@Test @Test
fun `when location service disabled is displayed user can open location settings`() { fun `when location service disabled is displayed user can open location settings`() = runAndroidComposeUiTest {
val eventsRecorder = EventsRecorder<ShareLocationEvent>() val eventsRecorder = EventsRecorder<ShareLocationEvent>()
rule.setShareLocationView( setShareLocationView(
aShareLocationState( aShareLocationState(
dialogState = ShareLocationState.Dialog.Constraints(LocationConstraintsDialogState.LocationServiceDisabled), dialogState = ShareLocationState.Dialog.Constraints(LocationConstraintsDialogState.LocationServiceDisabled),
hasLocationPermission = true, hasLocationPermission = true,
@ -126,14 +125,14 @@ class ShareLocationViewTest {
), ),
navigateUp = EnsureNeverCalled(), navigateUp = EnsureNeverCalled(),
) )
rule.clickOn(CommonStrings.action_continue) clickOn(CommonStrings.action_continue)
eventsRecorder.assertSingle(ShareLocationEvent.OpenLocationSettings) eventsRecorder.assertSingle(ShareLocationEvent.OpenLocationSettings)
} }
@Test @Test
fun `when location service disabled is displayed user can close the dialog`() { fun `when location service disabled is displayed user can close the dialog`() = runAndroidComposeUiTest {
val eventsRecorder = EventsRecorder<ShareLocationEvent>() val eventsRecorder = EventsRecorder<ShareLocationEvent>()
rule.setShareLocationView( setShareLocationView(
aShareLocationState( aShareLocationState(
dialogState = ShareLocationState.Dialog.Constraints(LocationConstraintsDialogState.LocationServiceDisabled), dialogState = ShareLocationState.Dialog.Constraints(LocationConstraintsDialogState.LocationServiceDisabled),
hasLocationPermission = true, hasLocationPermission = true,
@ -141,12 +140,12 @@ class ShareLocationViewTest {
), ),
navigateUp = EnsureNeverCalled(), navigateUp = EnsureNeverCalled(),
) )
rule.clickOn(CommonStrings.action_cancel) clickOn(CommonStrings.action_cancel)
eventsRecorder.assertSingle(ShareLocationEvent.DismissDialog) eventsRecorder.assertSingle(ShareLocationEvent.DismissDialog)
} }
} }
private fun <R : TestRule> AndroidComposeTestRule<R, ComponentActivity>.setShareLocationView( private fun AndroidComposeUiTest<ComponentActivity>.setShareLocationView(
state: ShareLocationState, state: ShareLocationState,
navigateUp: () -> Unit = EnsureNeverCalled(), navigateUp: () -> Unit = EnsureNeverCalled(),
) { ) {

View file

@ -6,16 +6,19 @@
* Please see LICENSE files in the repository root for full details. * Please see LICENSE files in the repository root for full details.
*/ */
@file:OptIn(ExperimentalTestApi::class)
package io.element.android.features.location.impl.show package io.element.android.features.location.impl.show
import androidx.activity.ComponentActivity import androidx.activity.ComponentActivity
import androidx.compose.runtime.CompositionLocalProvider import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.ui.platform.LocalInspectionMode import androidx.compose.ui.platform.LocalInspectionMode
import androidx.compose.ui.test.junit4.AndroidComposeTestRule import androidx.compose.ui.test.AndroidComposeUiTest
import androidx.compose.ui.test.junit4.createAndroidComposeRule import androidx.compose.ui.test.ExperimentalTestApi
import androidx.compose.ui.test.onNodeWithContentDescription import androidx.compose.ui.test.onNodeWithContentDescription
import androidx.compose.ui.test.onNodeWithTag import androidx.compose.ui.test.onNodeWithTag
import androidx.compose.ui.test.performClick import androidx.compose.ui.test.performClick
import androidx.compose.ui.test.v2.runAndroidComposeUiTest
import androidx.test.ext.junit.runners.AndroidJUnit4 import androidx.test.ext.junit.runners.AndroidJUnit4
import io.element.android.features.location.api.Location import io.element.android.features.location.api.Location
import io.element.android.features.location.impl.common.ui.LocationConstraintsDialogState import io.element.android.features.location.impl.common.ui.LocationConstraintsDialogState
@ -26,115 +29,111 @@ import io.element.android.tests.testutils.EventsRecorder
import io.element.android.tests.testutils.clickOn import io.element.android.tests.testutils.clickOn
import io.element.android.tests.testutils.ensureCalledOnce import io.element.android.tests.testutils.ensureCalledOnce
import io.element.android.tests.testutils.pressBack import io.element.android.tests.testutils.pressBack
import org.junit.Rule
import org.junit.Test import org.junit.Test
import org.junit.rules.TestRule
import org.junit.runner.RunWith import org.junit.runner.RunWith
@RunWith(AndroidJUnit4::class) @RunWith(AndroidJUnit4::class)
class ShowLocationViewTest { class ShowLocationViewTest {
@get:Rule val rule = createAndroidComposeRule<ComponentActivity>()
@Test @Test
fun `test back action`() { fun `test back action`() = runAndroidComposeUiTest {
val eventsRecorder = EventsRecorder<ShowLocationEvent>(expectEvents = false) val eventsRecorder = EventsRecorder<ShowLocationEvent>(expectEvents = false)
ensureCalledOnce { callback -> ensureCalledOnce { callback ->
rule.setShowLocationView( setShowLocationView(
state = aShowLocationState( state = aShowLocationState(
eventSink = eventsRecorder eventSink = eventsRecorder
), ),
onBackClick = callback, onBackClick = callback,
) )
rule.pressBack() pressBack()
} }
} }
@Test @Test
fun `test share action`() { fun `test share action`() = runAndroidComposeUiTest {
val eventsRecorder = EventsRecorder<ShowLocationEvent>() val eventsRecorder = EventsRecorder<ShowLocationEvent>()
rule.setShowLocationView( setShowLocationView(
aShowLocationState( aShowLocationState(
eventSink = eventsRecorder eventSink = eventsRecorder
), ),
onBackClick = EnsureNeverCalled(), onBackClick = EnsureNeverCalled(),
) )
val shareContentDescription = rule.activity.getString(CommonStrings.action_share) val shareContentDescription = activity!!.getString(CommonStrings.action_share)
rule.onNodeWithContentDescription(shareContentDescription).performClick() onNodeWithContentDescription(shareContentDescription).performClick()
// The default aStaticLocationMode uses Location(1.23, 2.34, 4f) // The default aStaticLocationMode uses Location(1.23, 2.34, 4f)
eventsRecorder.assertSingle(ShowLocationEvent.Share(Location(1.23, 2.34, 4f))) eventsRecorder.assertSingle(ShowLocationEvent.Share(Location(1.23, 2.34, 4f)))
} }
@Test @Test
fun `test fab click`() { fun `test fab click`() = runAndroidComposeUiTest {
val eventsRecorder = EventsRecorder<ShowLocationEvent>() val eventsRecorder = EventsRecorder<ShowLocationEvent>()
rule.setShowLocationView( setShowLocationView(
aShowLocationState( aShowLocationState(
eventSink = eventsRecorder eventSink = eventsRecorder
), ),
onBackClick = EnsureNeverCalled(), onBackClick = EnsureNeverCalled(),
) )
rule.onNodeWithTag(TestTags.floatingActionButton.value).performClick() onNodeWithTag(TestTags.floatingActionButton.value).performClick()
eventsRecorder.assertSingle(ShowLocationEvent.TrackMyLocation(true)) eventsRecorder.assertSingle(ShowLocationEvent.TrackMyLocation(true))
} }
@Test @Test
fun `when permission denied is displayed user can open the settings`() { fun `when permission denied is displayed user can open the settings`() = runAndroidComposeUiTest {
val eventsRecorder = EventsRecorder<ShowLocationEvent>() val eventsRecorder = EventsRecorder<ShowLocationEvent>()
rule.setShowLocationView( setShowLocationView(
aShowLocationState( aShowLocationState(
constraintsDialogState = LocationConstraintsDialogState.PermissionDenied, constraintsDialogState = LocationConstraintsDialogState.PermissionDenied,
eventSink = eventsRecorder eventSink = eventsRecorder
), ),
onBackClick = EnsureNeverCalled(), onBackClick = EnsureNeverCalled(),
) )
rule.clickOn(CommonStrings.action_continue) clickOn(CommonStrings.action_continue)
eventsRecorder.assertSingle(ShowLocationEvent.OpenAppSettings) eventsRecorder.assertSingle(ShowLocationEvent.OpenAppSettings)
} }
@Test @Test
fun `when permission denied is displayed user can close the dialog`() { fun `when permission denied is displayed user can close the dialog`() = runAndroidComposeUiTest {
val eventsRecorder = EventsRecorder<ShowLocationEvent>() val eventsRecorder = EventsRecorder<ShowLocationEvent>()
rule.setShowLocationView( setShowLocationView(
aShowLocationState( aShowLocationState(
constraintsDialogState = LocationConstraintsDialogState.PermissionDenied, constraintsDialogState = LocationConstraintsDialogState.PermissionDenied,
eventSink = eventsRecorder eventSink = eventsRecorder
), ),
onBackClick = EnsureNeverCalled(), onBackClick = EnsureNeverCalled(),
) )
rule.clickOn(CommonStrings.action_cancel) clickOn(CommonStrings.action_cancel)
eventsRecorder.assertSingle(ShowLocationEvent.DismissDialog) eventsRecorder.assertSingle(ShowLocationEvent.DismissDialog)
} }
@Test @Test
fun `when permission rationale is displayed user can request permissions`() { fun `when permission rationale is displayed user can request permissions`() = runAndroidComposeUiTest {
val eventsRecorder = EventsRecorder<ShowLocationEvent>() val eventsRecorder = EventsRecorder<ShowLocationEvent>()
rule.setShowLocationView( setShowLocationView(
aShowLocationState( aShowLocationState(
constraintsDialogState = LocationConstraintsDialogState.PermissionRationale, constraintsDialogState = LocationConstraintsDialogState.PermissionRationale,
eventSink = eventsRecorder eventSink = eventsRecorder
), ),
onBackClick = EnsureNeverCalled(), onBackClick = EnsureNeverCalled(),
) )
rule.clickOn(CommonStrings.action_continue) clickOn(CommonStrings.action_continue)
eventsRecorder.assertSingle(ShowLocationEvent.RequestPermissions) eventsRecorder.assertSingle(ShowLocationEvent.RequestPermissions)
} }
@Test @Test
fun `when permission rationale is displayed user can close the dialog`() { fun `when permission rationale is displayed user can close the dialog`() = runAndroidComposeUiTest {
val eventsRecorder = EventsRecorder<ShowLocationEvent>() val eventsRecorder = EventsRecorder<ShowLocationEvent>()
rule.setShowLocationView( setShowLocationView(
aShowLocationState( aShowLocationState(
constraintsDialogState = LocationConstraintsDialogState.PermissionRationale, constraintsDialogState = LocationConstraintsDialogState.PermissionRationale,
eventSink = eventsRecorder eventSink = eventsRecorder
), ),
onBackClick = EnsureNeverCalled(), onBackClick = EnsureNeverCalled(),
) )
rule.clickOn(CommonStrings.action_cancel) clickOn(CommonStrings.action_cancel)
eventsRecorder.assertSingle(ShowLocationEvent.DismissDialog) eventsRecorder.assertSingle(ShowLocationEvent.DismissDialog)
} }
} }
private fun <R : TestRule> AndroidComposeTestRule<R, ComponentActivity>.setShowLocationView( private fun AndroidComposeUiTest<ComponentActivity>.setShowLocationView(
state: ShowLocationState, state: ShowLocationState,
onBackClick: () -> Unit = EnsureNeverCalled(), onBackClick: () -> Unit = EnsureNeverCalled(),
) { ) {

View file

@ -16,7 +16,7 @@ android {
dependencies { dependencies {
api(projects.features.location.api) api(projects.features.location.api)
implementation(projects.libraries.architecture)
implementation(projects.libraries.matrix.api) implementation(projects.libraries.matrix.api)
implementation(libs.appyx.core)
implementation(projects.tests.testutils) implementation(projects.tests.testutils)
} }

View file

@ -6,60 +6,57 @@
* Please see LICENSE files in the repository root for full details. * Please see LICENSE files in the repository root for full details.
*/ */
@file:OptIn(ExperimentalTestApi::class)
package io.element.android.features.lockscreen.impl.unlock.keypad package io.element.android.features.lockscreen.impl.unlock.keypad
import android.view.KeyEvent import android.view.KeyEvent
import androidx.activity.ComponentActivity import androidx.activity.ComponentActivity
import androidx.compose.ui.input.key.Key import androidx.compose.ui.input.key.Key
import androidx.compose.ui.test.AndroidComposeUiTest
import androidx.compose.ui.test.ExperimentalTestApi import androidx.compose.ui.test.ExperimentalTestApi
import androidx.compose.ui.test.hasContentDescription import androidx.compose.ui.test.hasContentDescription
import androidx.compose.ui.test.hasText import androidx.compose.ui.test.hasText
import androidx.compose.ui.test.isRoot import androidx.compose.ui.test.isRoot
import androidx.compose.ui.test.junit4.AndroidComposeTestRule
import androidx.compose.ui.test.junit4.createAndroidComposeRule
import androidx.compose.ui.test.onNodeWithText import androidx.compose.ui.test.onNodeWithText
import androidx.compose.ui.test.performClick import androidx.compose.ui.test.performClick
import androidx.compose.ui.test.performKeyInput import androidx.compose.ui.test.performKeyInput
import androidx.compose.ui.test.pressKey import androidx.compose.ui.test.pressKey
import androidx.compose.ui.test.requestFocus import androidx.compose.ui.test.requestFocus
import androidx.compose.ui.test.v2.runAndroidComposeUiTest
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import io.element.android.libraries.ui.strings.CommonStrings import io.element.android.libraries.ui.strings.CommonStrings
import io.element.android.tests.testutils.EnsureNeverCalledWithParam import io.element.android.tests.testutils.EnsureNeverCalledWithParam
import io.element.android.tests.testutils.EventsRecorder import io.element.android.tests.testutils.EventsRecorder
import org.junit.Rule
import org.junit.Test import org.junit.Test
import org.junit.rules.TestRule
import org.junit.runner.RunWith import org.junit.runner.RunWith
import org.robolectric.RobolectricTestRunner import org.robolectric.RobolectricTestRunner
@RunWith(RobolectricTestRunner::class) @RunWith(RobolectricTestRunner::class)
class PinKeypadTest { class PinKeypadTest {
@get:Rule
val rule = createAndroidComposeRule<ComponentActivity>()
@Test @Test
fun `clicking on a number emits the expected event`() { fun `clicking on a number emits the expected event`() = runAndroidComposeUiTest {
val eventsRecorder = EventsRecorder<PinKeypadModel>() val eventsRecorder = EventsRecorder<PinKeypadModel>()
rule.setPinKeyPad(onClick = eventsRecorder) setPinKeyPad(onClick = eventsRecorder)
rule.onNode(hasText("1")).performClick() onNode(hasText("1")).performClick()
eventsRecorder.assertSingle(PinKeypadModel.Number('1')) eventsRecorder.assertSingle(PinKeypadModel.Number('1'))
} }
@Test @Test
fun `clicking on the delete previous character button emits the expected event`() { fun `clicking on the delete previous character button emits the expected event`() = runAndroidComposeUiTest {
val eventsRecorder = EventsRecorder<PinKeypadModel>() val eventsRecorder = EventsRecorder<PinKeypadModel>()
rule.setPinKeyPad(onClick = eventsRecorder) setPinKeyPad(onClick = eventsRecorder)
rule.onNode(hasContentDescription(rule.activity.getString(CommonStrings.a11y_delete))).performClick() onNode(hasContentDescription(activity!!.getString(CommonStrings.a11y_delete))).performClick()
eventsRecorder.assertSingle(PinKeypadModel.Back) eventsRecorder.assertSingle(PinKeypadModel.Back)
} }
@OptIn(ExperimentalTestApi::class) @OptIn(ExperimentalTestApi::class)
@Test @Test
fun `typing using the hardware keyboard emits the expected events`() { fun `typing using the hardware keyboard emits the expected events`() = runAndroidComposeUiTest {
val eventsRecorder = EventsRecorder<PinKeypadModel>() val eventsRecorder = EventsRecorder<PinKeypadModel>()
rule.setPinKeyPad(onClick = eventsRecorder) setPinKeyPad(onClick = eventsRecorder)
rule.onNodeWithText("1").requestFocus() onNodeWithText("1").requestFocus()
rule.onAllNodes(isRoot())[0].performKeyInput { onAllNodes(isRoot())[0].performKeyInput {
val keys = listOf( val keys = listOf(
Key.A, Key.A,
Key.NumPad1, Key.NumPad1,
@ -118,7 +115,7 @@ class PinKeypadTest {
) )
} }
private fun <R : TestRule> AndroidComposeTestRule<R, ComponentActivity>.setPinKeyPad( private fun AndroidComposeUiTest<ComponentActivity>.setPinKeyPad(
onClick: (PinKeypadModel) -> Unit = EnsureNeverCalledWithParam(), onClick: (PinKeypadModel) -> Unit = EnsureNeverCalledWithParam(),
) { ) {
setContent { setContent {

View file

@ -6,13 +6,16 @@
* Please see LICENSE files in the repository root for full details. * Please see LICENSE files in the repository root for full details.
*/ */
@file:OptIn(ExperimentalTestApi::class)
package io.element.android.features.login.impl.screens.chooseaccountprovider package io.element.android.features.login.impl.screens.chooseaccountprovider
import androidx.activity.ComponentActivity import androidx.activity.ComponentActivity
import androidx.compose.ui.test.junit4.AndroidComposeTestRule import androidx.compose.ui.test.AndroidComposeUiTest
import androidx.compose.ui.test.junit4.createAndroidComposeRule import androidx.compose.ui.test.ExperimentalTestApi
import androidx.compose.ui.test.onNodeWithText import androidx.compose.ui.test.onNodeWithText
import androidx.compose.ui.test.performClick import androidx.compose.ui.test.performClick
import androidx.compose.ui.test.v2.runAndroidComposeUiTest
import androidx.test.ext.junit.runners.AndroidJUnit4 import androidx.test.ext.junit.runners.AndroidJUnit4
import io.element.android.features.login.impl.accountprovider.anAccountProvider import io.element.android.features.login.impl.accountprovider.anAccountProvider
import io.element.android.libraries.architecture.AsyncData import io.element.android.libraries.architecture.AsyncData
@ -25,36 +28,31 @@ import io.element.android.tests.testutils.EventsRecorder
import io.element.android.tests.testutils.clickOn import io.element.android.tests.testutils.clickOn
import io.element.android.tests.testutils.ensureCalledOnce import io.element.android.tests.testutils.ensureCalledOnce
import io.element.android.tests.testutils.pressBack import io.element.android.tests.testutils.pressBack
import org.junit.Rule
import org.junit.Test import org.junit.Test
import org.junit.rules.TestRule
import org.junit.runner.RunWith import org.junit.runner.RunWith
import org.robolectric.annotation.Config import org.robolectric.annotation.Config
@RunWith(AndroidJUnit4::class) @RunWith(AndroidJUnit4::class)
class ChooseAccountProviderViewTest { class ChooseAccountProviderViewTest {
@get:Rule
val rule = createAndroidComposeRule<ComponentActivity>()
@Test @Test
fun `clicking on back invokes the expected callback`() { fun `clicking on back invokes the expected callback`() = runAndroidComposeUiTest {
val eventSink = EventsRecorder<ChooseAccountProviderEvents>(expectEvents = false) val eventSink = EventsRecorder<ChooseAccountProviderEvents>(expectEvents = false)
ensureCalledOnce { ensureCalledOnce {
rule.setChooseAccountProviderView( setChooseAccountProviderView(
state = aChooseAccountProviderState( state = aChooseAccountProviderState(
eventSink = eventSink, eventSink = eventSink,
), ),
onBackClick = it, onBackClick = it,
) )
rule.pressBack() pressBack()
} }
} }
@Config(qualifiers = "h1024dp") @Config(qualifiers = "h1024dp")
@Test @Test
fun `selecting an account provider emits the the expected event`() { fun `selecting an account provider emits the the expected event`() = runAndroidComposeUiTest {
val eventSink = EventsRecorder<ChooseAccountProviderEvents>() val eventSink = EventsRecorder<ChooseAccountProviderEvents>()
rule.setChooseAccountProviderView( setChooseAccountProviderView(
state = aChooseAccountProviderState( state = aChooseAccountProviderState(
accountProviders = listOf( accountProviders = listOf(
ChooseAccountProviderPresenterTest.accountProvider1, ChooseAccountProviderPresenterTest.accountProvider1,
@ -64,24 +62,24 @@ class ChooseAccountProviderViewTest {
eventSink = eventSink, eventSink = eventSink,
), ),
) )
rule.onNodeWithText(ChooseAccountProviderPresenterTest.accountProvider1.title).performClick() onNodeWithText(ChooseAccountProviderPresenterTest.accountProvider1.title).performClick()
eventSink.assertSingle(ChooseAccountProviderEvents.SelectAccountProvider(ChooseAccountProviderPresenterTest.accountProvider1)) eventSink.assertSingle(ChooseAccountProviderEvents.SelectAccountProvider(ChooseAccountProviderPresenterTest.accountProvider1))
} }
@Test @Test
fun `when error is displayed - closing the dialog emits the expected event`() { fun `when error is displayed - closing the dialog emits the expected event`() = runAndroidComposeUiTest {
val eventSink = EventsRecorder<ChooseAccountProviderEvents>() val eventSink = EventsRecorder<ChooseAccountProviderEvents>()
rule.setChooseAccountProviderView( setChooseAccountProviderView(
state = aChooseAccountProviderState( state = aChooseAccountProviderState(
loginMode = AsyncData.Failure(AN_EXCEPTION), loginMode = AsyncData.Failure(AN_EXCEPTION),
eventSink = eventSink, eventSink = eventSink,
), ),
) )
rule.clickOn(CommonStrings.action_ok) clickOn(CommonStrings.action_ok)
eventSink.assertSingle(ChooseAccountProviderEvents.ClearError) eventSink.assertSingle(ChooseAccountProviderEvents.ClearError)
} }
private fun <R : TestRule> AndroidComposeTestRule<R, ComponentActivity>.setChooseAccountProviderView( private fun AndroidComposeUiTest<ComponentActivity>.setChooseAccountProviderView(
state: ChooseAccountProviderState, state: ChooseAccountProviderState,
onBackClick: () -> Unit = EnsureNeverCalled(), onBackClick: () -> Unit = EnsureNeverCalled(),
onOAuthDetails: (OAuthDetails) -> Unit = EnsureNeverCalledWithParam(), onOAuthDetails: (OAuthDetails) -> Unit = EnsureNeverCalledWithParam(),

View file

@ -6,20 +6,23 @@
* Please see LICENSE files in the repository root for full details. * Please see LICENSE files in the repository root for full details.
*/ */
@file:OptIn(ExperimentalTestApi::class)
package io.element.android.features.login.impl.screens.loginpassword package io.element.android.features.login.impl.screens.loginpassword
import androidx.activity.ComponentActivity import androidx.activity.ComponentActivity
import androidx.compose.ui.test.AndroidComposeUiTest
import androidx.compose.ui.test.ExperimentalTestApi
import androidx.compose.ui.test.assert import androidx.compose.ui.test.assert
import androidx.compose.ui.test.assertIsEnabled import androidx.compose.ui.test.assertIsEnabled
import androidx.compose.ui.test.assertIsNotEnabled import androidx.compose.ui.test.assertIsNotEnabled
import androidx.compose.ui.test.hasText import androidx.compose.ui.test.hasText
import androidx.compose.ui.test.junit4.AndroidComposeTestRule
import androidx.compose.ui.test.junit4.createAndroidComposeRule
import androidx.compose.ui.test.onNodeWithContentDescription import androidx.compose.ui.test.onNodeWithContentDescription
import androidx.compose.ui.test.onNodeWithTag import androidx.compose.ui.test.onNodeWithTag
import androidx.compose.ui.test.onNodeWithText import androidx.compose.ui.test.onNodeWithText
import androidx.compose.ui.test.performClick import androidx.compose.ui.test.performClick
import androidx.compose.ui.test.performTextInput import androidx.compose.ui.test.performTextInput
import androidx.compose.ui.test.v2.runAndroidComposeUiTest
import androidx.test.ext.junit.runners.AndroidJUnit4 import androidx.test.ext.junit.runners.AndroidJUnit4
import io.element.android.libraries.matrix.test.A_PASSWORD import io.element.android.libraries.matrix.test.A_PASSWORD
import io.element.android.libraries.matrix.test.A_USER_NAME import io.element.android.libraries.matrix.test.A_USER_NAME
@ -30,158 +33,154 @@ import io.element.android.tests.testutils.EventsRecorder
import io.element.android.tests.testutils.clickOn import io.element.android.tests.testutils.clickOn
import io.element.android.tests.testutils.ensureCalledOnce import io.element.android.tests.testutils.ensureCalledOnce
import io.element.android.tests.testutils.pressBack import io.element.android.tests.testutils.pressBack
import org.junit.Rule
import org.junit.Test import org.junit.Test
import org.junit.rules.TestRule
import org.junit.runner.RunWith import org.junit.runner.RunWith
import org.robolectric.annotation.Config import org.robolectric.annotation.Config
@RunWith(AndroidJUnit4::class) @RunWith(AndroidJUnit4::class)
class LoginPasswordViewTest { class LoginPasswordViewTest {
@get:Rule
val rule = createAndroidComposeRule<ComponentActivity>()
@Test @Test
fun `clicking on back invoke back callback`() { fun `clicking on back invoke back callback`() = runAndroidComposeUiTest {
val eventsRecorder = EventsRecorder<LoginPasswordEvents>(expectEvents = false) val eventsRecorder = EventsRecorder<LoginPasswordEvents>(expectEvents = false)
ensureCalledOnce { callback -> ensureCalledOnce { callback ->
rule.setLoginPasswordView( setLoginPasswordView(
aLoginPasswordState( aLoginPasswordState(
eventSink = eventsRecorder eventSink = eventsRecorder
), ),
onBackClick = callback, onBackClick = callback,
) )
rule.pressBack() pressBack()
} }
} }
@Test @Test
fun `changing login invokes the expected event`() { fun `changing login invokes the expected event`() = runAndroidComposeUiTest {
val eventsRecorder = EventsRecorder<LoginPasswordEvents>() val eventsRecorder = EventsRecorder<LoginPasswordEvents>()
rule.setLoginPasswordView( setLoginPasswordView(
aLoginPasswordState( aLoginPasswordState(
eventSink = eventsRecorder, eventSink = eventsRecorder,
), ),
) )
val userNameHint = rule.activity.getString(CommonStrings.common_username) val userNameHint = activity!!.getString(CommonStrings.common_username)
rule.onNodeWithText(userNameHint).performTextInput(A_USER_NAME) onNodeWithText(userNameHint).performTextInput(A_USER_NAME)
eventsRecorder.assertSingle( eventsRecorder.assertSingle(
LoginPasswordEvents.SetLogin(A_USER_NAME) LoginPasswordEvents.SetLogin(A_USER_NAME)
) )
} }
@Test @Test
fun `changing login removes new lines the expected event`() { fun `changing login removes new lines the expected event`() = runAndroidComposeUiTest {
val eventsRecorder = EventsRecorder<LoginPasswordEvents>() val eventsRecorder = EventsRecorder<LoginPasswordEvents>()
rule.setLoginPasswordView( setLoginPasswordView(
aLoginPasswordState( aLoginPasswordState(
eventSink = eventsRecorder, eventSink = eventsRecorder,
), ),
) )
val userNameHint = rule.activity.getString(CommonStrings.common_username) val userNameHint = activity!!.getString(CommonStrings.common_username)
rule.onNodeWithText(userNameHint).performTextInput("a\nb") onNodeWithText(userNameHint).performTextInput("a\nb")
eventsRecorder.assertSingle( eventsRecorder.assertSingle(
LoginPasswordEvents.SetLogin("ab") LoginPasswordEvents.SetLogin("ab")
) )
} }
@Test @Test
fun `clearing login invokes the expected event`() { fun `clearing login invokes the expected event`() = runAndroidComposeUiTest {
val eventsRecorder = EventsRecorder<LoginPasswordEvents>() val eventsRecorder = EventsRecorder<LoginPasswordEvents>()
rule.setLoginPasswordView( setLoginPasswordView(
aLoginPasswordState( aLoginPasswordState(
formState = aLoginFormState(A_USER_NAME), formState = aLoginFormState(A_USER_NAME),
eventSink = eventsRecorder, eventSink = eventsRecorder,
), ),
) )
val a11yClear = rule.activity.getString(CommonStrings.action_clear) val a11yClear = activity!!.getString(CommonStrings.action_clear)
rule.onNodeWithContentDescription(a11yClear).performClick() onNodeWithContentDescription(a11yClear).performClick()
eventsRecorder.assertSingle( eventsRecorder.assertSingle(
LoginPasswordEvents.SetLogin("") LoginPasswordEvents.SetLogin("")
) )
} }
@Test @Test
fun `changing password invokes the expected event`() { fun `changing password invokes the expected event`() = runAndroidComposeUiTest {
val eventsRecorder = EventsRecorder<LoginPasswordEvents>() val eventsRecorder = EventsRecorder<LoginPasswordEvents>()
rule.setLoginPasswordView( setLoginPasswordView(
aLoginPasswordState( aLoginPasswordState(
eventSink = eventsRecorder, eventSink = eventsRecorder,
), ),
) )
val userNameHint = rule.activity.getString(CommonStrings.common_password) val userNameHint = activity!!.getString(CommonStrings.common_password)
rule.onNodeWithText(userNameHint).performTextInput(A_PASSWORD) onNodeWithText(userNameHint).performTextInput(A_PASSWORD)
eventsRecorder.assertSingle( eventsRecorder.assertSingle(
LoginPasswordEvents.SetPassword(A_PASSWORD) LoginPasswordEvents.SetPassword(A_PASSWORD)
) )
} }
@Test @Test
fun `reveal password makes the password visible`() { fun `reveal password makes the password visible`() = runAndroidComposeUiTest {
val eventsRecorder = EventsRecorder<LoginPasswordEvents>(expectEvents = false) val eventsRecorder = EventsRecorder<LoginPasswordEvents>(expectEvents = false)
rule.setLoginPasswordView( setLoginPasswordView(
aLoginPasswordState( aLoginPasswordState(
formState = aLoginFormState(password = A_PASSWORD), formState = aLoginFormState(password = A_PASSWORD),
eventSink = eventsRecorder, eventSink = eventsRecorder,
), ),
) )
rule.onNodeWithTag(TestTags.loginPassword.value).assert(hasText("••••••••")) onNodeWithTag(TestTags.loginPassword.value).assert(hasText("••••••••"))
val resources = activity!!.resources
// Show password // Show password
val a11yShowPassword = rule.activity.getString(CommonStrings.a11y_show_password) val a11yShowPassword = resources.getString(CommonStrings.a11y_show_password)
rule.onNodeWithContentDescription(a11yShowPassword).performClick() onNodeWithContentDescription(a11yShowPassword).performClick()
rule.onNodeWithTag(TestTags.loginPassword.value).assert(hasText(A_PASSWORD)) onNodeWithTag(TestTags.loginPassword.value).assert(hasText(A_PASSWORD))
// Hide password // Hide password
val a11yHidePassword = rule.activity.getString(CommonStrings.a11y_hide_password) val a11yHidePassword = resources.getString(CommonStrings.a11y_hide_password)
rule.onNodeWithContentDescription(a11yHidePassword).performClick() onNodeWithContentDescription(a11yHidePassword).performClick()
rule.onNodeWithTag(TestTags.loginPassword.value).assert(hasText("••••••••")) onNodeWithTag(TestTags.loginPassword.value).assert(hasText("••••••••"))
} }
@Test @Test
fun `when login is empty, continue button is not enabled`() { fun `when login is empty, continue button is not enabled`() = runAndroidComposeUiTest {
val eventsRecorder = EventsRecorder<LoginPasswordEvents>(expectEvents = false) val eventsRecorder = EventsRecorder<LoginPasswordEvents>(expectEvents = false)
rule.setLoginPasswordView( setLoginPasswordView(
aLoginPasswordState( aLoginPasswordState(
formState = aLoginFormState(password = A_PASSWORD), formState = aLoginFormState(password = A_PASSWORD),
eventSink = eventsRecorder, eventSink = eventsRecorder,
), ),
) )
val continueStr = rule.activity.getString(CommonStrings.action_continue) val continueStr = activity!!.getString(CommonStrings.action_continue)
rule.onNodeWithText(continueStr).assertIsNotEnabled() onNodeWithText(continueStr).assertIsNotEnabled()
} }
@Test @Test
fun `when password is empty, continue button is not enabled`() { fun `when password is empty, continue button is not enabled`() = runAndroidComposeUiTest {
val eventsRecorder = EventsRecorder<LoginPasswordEvents>(expectEvents = false) val eventsRecorder = EventsRecorder<LoginPasswordEvents>(expectEvents = false)
rule.setLoginPasswordView( setLoginPasswordView(
aLoginPasswordState( aLoginPasswordState(
formState = aLoginFormState(login = A_USER_NAME), formState = aLoginFormState(login = A_USER_NAME),
eventSink = eventsRecorder, eventSink = eventsRecorder,
), ),
) )
val continueStr = rule.activity.getString(CommonStrings.action_continue) val continueStr = activity!!.getString(CommonStrings.action_continue)
rule.onNodeWithText(continueStr).assertIsNotEnabled() onNodeWithText(continueStr).assertIsNotEnabled()
} }
@Config(qualifiers = "h1024dp") @Config(qualifiers = "h1024dp")
@Test @Test
fun `clicking on Continue sends expected event`() { fun `clicking on Continue sends expected event`() = runAndroidComposeUiTest {
val eventsRecorder = EventsRecorder<LoginPasswordEvents>() val eventsRecorder = EventsRecorder<LoginPasswordEvents>()
rule.setLoginPasswordView( setLoginPasswordView(
aLoginPasswordState( aLoginPasswordState(
formState = aLoginFormState(login = A_USER_NAME, password = A_PASSWORD), formState = aLoginFormState(login = A_USER_NAME, password = A_PASSWORD),
eventSink = eventsRecorder, eventSink = eventsRecorder,
), ),
) )
val continueStr = rule.activity.getString(CommonStrings.action_continue) val continueStr = activity!!.getString(CommonStrings.action_continue)
rule.onNodeWithText(continueStr).assertIsEnabled() onNodeWithText(continueStr).assertIsEnabled()
rule.clickOn(CommonStrings.action_continue) clickOn(CommonStrings.action_continue)
eventsRecorder.assertSingle( eventsRecorder.assertSingle(
LoginPasswordEvents.Submit LoginPasswordEvents.Submit
) )
} }
} }
private fun <R : TestRule> AndroidComposeTestRule<R, ComponentActivity>.setLoginPasswordView( private fun AndroidComposeUiTest<ComponentActivity>.setLoginPasswordView(
state: LoginPasswordState, state: LoginPasswordState,
onBackClick: () -> Unit = EnsureNeverCalled(), onBackClick: () -> Unit = EnsureNeverCalled(),
) { ) {

View file

@ -6,14 +6,17 @@
* Please see LICENSE files in the repository root for full details. * Please see LICENSE files in the repository root for full details.
*/ */
@file:OptIn(ExperimentalTestApi::class)
package io.element.android.features.login.impl.screens.onboarding package io.element.android.features.login.impl.screens.onboarding
import androidx.activity.ComponentActivity import androidx.activity.ComponentActivity
import androidx.compose.ui.test.junit4.AndroidComposeTestRule import androidx.compose.ui.test.AndroidComposeUiTest
import androidx.compose.ui.test.junit4.createAndroidComposeRule import androidx.compose.ui.test.ExperimentalTestApi
import androidx.compose.ui.test.onNodeWithContentDescription import androidx.compose.ui.test.onNodeWithContentDescription
import androidx.compose.ui.test.onNodeWithText import androidx.compose.ui.test.onNodeWithText
import androidx.compose.ui.test.performClick import androidx.compose.ui.test.performClick
import androidx.compose.ui.test.v2.runAndroidComposeUiTest
import com.google.testing.junit.testparameterinjector.KotlinTestParameters.namedTestValues import com.google.testing.junit.testparameterinjector.KotlinTestParameters.namedTestValues
import com.google.testing.junit.testparameterinjector.TestParameter import com.google.testing.junit.testparameterinjector.TestParameter
import io.element.android.features.login.impl.R import io.element.android.features.login.impl.R
@ -29,22 +32,17 @@ import io.element.android.tests.testutils.clickOn
import io.element.android.tests.testutils.ensureCalledOnce import io.element.android.tests.testutils.ensureCalledOnce
import io.element.android.tests.testutils.ensureCalledOnceWithParam import io.element.android.tests.testutils.ensureCalledOnceWithParam
import io.element.android.tests.testutils.pressBack import io.element.android.tests.testutils.pressBack
import org.junit.Rule
import org.junit.Test import org.junit.Test
import org.junit.rules.TestRule
import org.junit.runner.RunWith import org.junit.runner.RunWith
import org.robolectric.RobolectricTestParameterInjector import org.robolectric.RobolectricTestParameterInjector
@RunWith(RobolectricTestParameterInjector::class) @RunWith(RobolectricTestParameterInjector::class)
class OnboardingViewTest { class OnboardingViewTest {
@get:Rule
val rule = createAndroidComposeRule<ComponentActivity>()
@Test @Test
fun `when can create account - clicking on create account calls the expected callback`() { fun `when can create account - clicking on create account calls the expected callback`() = runAndroidComposeUiTest {
val eventSink = EventsRecorder<OnBoardingEvents>(expectEvents = false) val eventSink = EventsRecorder<OnBoardingEvents>(expectEvents = false)
ensureCalledOnce { callback -> ensureCalledOnce { callback ->
rule.setOnboardingView( setOnboardingView(
state = anOnBoardingState( state = anOnBoardingState(
canCreateAccount = true, canCreateAccount = true,
showDeveloperSettings = false, showDeveloperSettings = false,
@ -52,40 +50,40 @@ class OnboardingViewTest {
), ),
onCreateAccount = callback, onCreateAccount = callback,
) )
rule.clickOn(R.string.screen_onboarding_sign_up) clickOn(R.string.screen_onboarding_sign_up)
// Developer settings should not be shown // Developer settings should not be shown
val developerSettingsText = rule.activity.getString(CommonStrings.common_developer_options) val developerSettingsText = activity!!.getString(CommonStrings.common_developer_options)
rule.onNodeWithContentDescription(developerSettingsText).assertDoesNotExist() onNodeWithContentDescription(developerSettingsText).assertDoesNotExist()
} }
} }
@Test @Test
fun `when can go back - clicking on back calls the expected callback`() { fun `when can go back - clicking on back calls the expected callback`() = runAndroidComposeUiTest {
val eventSink = EventsRecorder<OnBoardingEvents>(expectEvents = false) val eventSink = EventsRecorder<OnBoardingEvents>(expectEvents = false)
ensureCalledOnce { callback -> ensureCalledOnce { callback ->
rule.setOnboardingView( setOnboardingView(
state = anOnBoardingState( state = anOnBoardingState(
isAddingAccount = true, isAddingAccount = true,
eventSink = eventSink, eventSink = eventSink,
), ),
onBackClick = callback, onBackClick = callback,
) )
rule.pressBack() pressBack()
} }
} }
@Test @Test
fun `when can login with QR code - clicking on sign in with QR code calls the expected callback`() { fun `when can login with QR code - clicking on sign in with QR code calls the expected callback`() = runAndroidComposeUiTest {
val eventSink = EventsRecorder<OnBoardingEvents>(expectEvents = false) val eventSink = EventsRecorder<OnBoardingEvents>(expectEvents = false)
ensureCalledOnce { callback -> ensureCalledOnce { callback ->
rule.setOnboardingView( setOnboardingView(
state = anOnBoardingState( state = anOnBoardingState(
canLoginWithQrCode = true, canLoginWithQrCode = true,
eventSink = eventSink, eventSink = eventSink,
), ),
onSignInWithQrCode = callback, onSignInWithQrCode = callback,
) )
rule.clickOn(R.string.screen_onboarding_sign_in_with_qr_code) clickOn(R.string.screen_onboarding_sign_in_with_qr_code)
} }
} }
@ -95,10 +93,10 @@ class OnboardingViewTest {
"can search account provider" to false, "can search account provider" to false,
"cannot search account provider" to true, "cannot search account provider" to true,
) )
) { ) = runAndroidComposeUiTest {
val eventSink = EventsRecorder<OnBoardingEvents>(expectEvents = false) val eventSink = EventsRecorder<OnBoardingEvents>(expectEvents = false)
ensureCalledOnceWithParam(mustChooseAccountProvider) { callback -> ensureCalledOnceWithParam(mustChooseAccountProvider) { callback ->
rule.setOnboardingView( setOnboardingView(
state = anOnBoardingState( state = anOnBoardingState(
canLoginWithQrCode = true, canLoginWithQrCode = true,
mustChooseAccountProvider = mustChooseAccountProvider, mustChooseAccountProvider = mustChooseAccountProvider,
@ -106,7 +104,7 @@ class OnboardingViewTest {
), ),
onSignIn = callback, onSignIn = callback,
) )
rule.clickOn(R.string.screen_onboarding_sign_in_manually) clickOn(R.string.screen_onboarding_sign_in_manually)
} }
} }
@ -116,10 +114,10 @@ class OnboardingViewTest {
"can search account provider" to false, "can search account provider" to false,
"cannot search account provider" to true, "cannot search account provider" to true,
) )
) { ) = runAndroidComposeUiTest {
val eventSink = EventsRecorder<OnBoardingEvents>(expectEvents = false) val eventSink = EventsRecorder<OnBoardingEvents>(expectEvents = false)
ensureCalledOnceWithParam(mustChooseAccountProvider) { callback -> ensureCalledOnceWithParam(mustChooseAccountProvider) { callback ->
rule.setOnboardingView( setOnboardingView(
state = anOnBoardingState( state = anOnBoardingState(
canLoginWithQrCode = false, canLoginWithQrCode = false,
canCreateAccount = false, canCreateAccount = false,
@ -128,89 +126,89 @@ class OnboardingViewTest {
), ),
onSignIn = callback, onSignIn = callback,
) )
rule.clickOn(CommonStrings.action_continue) clickOn(CommonStrings.action_continue)
} }
} }
@Test @Test
fun `when sign in to pre defined account provider - clicking on button emits the expected event`() { fun `when sign in to pre defined account provider - clicking on button emits the expected event`() = runAndroidComposeUiTest {
val eventSink = EventsRecorder<OnBoardingEvents>() val eventSink = EventsRecorder<OnBoardingEvents>()
rule.setOnboardingView( setOnboardingView(
state = anOnBoardingState( state = anOnBoardingState(
defaultAccountProvider = "element.io", defaultAccountProvider = "element.io",
eventSink = eventSink, eventSink = eventSink,
), ),
) )
val buttonText = rule.activity.getString(R.string.screen_onboarding_sign_in_to, "element.io") val buttonText = activity!!.getString(R.string.screen_onboarding_sign_in_to, "element.io")
rule.onNodeWithText(buttonText).performClick() onNodeWithText(buttonText).performClick()
eventSink.assertSingle(OnBoardingEvents.OnSignIn("element.io")) eventSink.assertSingle(OnBoardingEvents.OnSignIn("element.io"))
} }
@Test @Test
fun `when error is displayed - closing the dialog emits the expected event`() { fun `when error is displayed - closing the dialog emits the expected event`() = runAndroidComposeUiTest {
val eventSink = EventsRecorder<OnBoardingEvents>() val eventSink = EventsRecorder<OnBoardingEvents>()
rule.setOnboardingView( setOnboardingView(
state = anOnBoardingState( state = anOnBoardingState(
defaultAccountProvider = "element.io", defaultAccountProvider = "element.io",
loginMode = AsyncData.Failure(AN_EXCEPTION), loginMode = AsyncData.Failure(AN_EXCEPTION),
eventSink = eventSink, eventSink = eventSink,
), ),
) )
rule.clickOn(CommonStrings.action_ok) clickOn(CommonStrings.action_ok)
eventSink.assertSingle(OnBoardingEvents.ClearError) eventSink.assertSingle(OnBoardingEvents.ClearError)
} }
@Test @Test
fun `clicking on report a problem calls the sign in callback`() { fun `clicking on report a problem calls the sign in callback`() = runAndroidComposeUiTest {
val eventSink = EventsRecorder<OnBoardingEvents>(expectEvents = false) val eventSink = EventsRecorder<OnBoardingEvents>(expectEvents = false)
ensureCalledOnce { callback -> ensureCalledOnce { callback ->
rule.setOnboardingView( setOnboardingView(
state = anOnBoardingState( state = anOnBoardingState(
canReportBug = true, canReportBug = true,
eventSink = eventSink, eventSink = eventSink,
), ),
onReportProblem = callback, onReportProblem = callback,
) )
val text = rule.activity.getString(CommonStrings.common_report_a_problem) val text = activity!!.getString(CommonStrings.common_report_a_problem)
rule.onNodeWithText(text).assertExists() onNodeWithText(text).assertExists()
rule.clickOn(CommonStrings.common_report_a_problem) clickOn(CommonStrings.common_report_a_problem)
} }
} }
@Test @Test
fun `clicking on settings calls the developer settings callback`() { fun `clicking on settings calls the developer settings callback`() = runAndroidComposeUiTest {
val eventSink = EventsRecorder<OnBoardingEvents>(expectEvents = false) val eventSink = EventsRecorder<OnBoardingEvents>(expectEvents = false)
ensureCalledOnce { callback -> ensureCalledOnce { callback ->
rule.setOnboardingView( setOnboardingView(
state = anOnBoardingState( state = anOnBoardingState(
showDeveloperSettings = true, showDeveloperSettings = true,
eventSink = eventSink, eventSink = eventSink,
), ),
onDeveloperSettingsClick = callback, onDeveloperSettingsClick = callback,
) )
val text = rule.activity.getString(CommonStrings.common_developer_options) val text = activity!!.getString(CommonStrings.common_developer_options)
rule.onNodeWithContentDescription(text).performClick() onNodeWithContentDescription(text).performClick()
} }
} }
@Test @Test
fun `cannot report a problem when the feature is disabled`() { fun `cannot report a problem when the feature is disabled`() = runAndroidComposeUiTest {
val eventSink = EventsRecorder<OnBoardingEvents>(expectEvents = false) val eventSink = EventsRecorder<OnBoardingEvents>(expectEvents = false)
rule.setOnboardingView( setOnboardingView(
state = anOnBoardingState( state = anOnBoardingState(
canReportBug = false, canReportBug = false,
eventSink = eventSink, eventSink = eventSink,
), ),
) )
val text = rule.activity.getString(CommonStrings.common_report_a_problem) val text = activity!!.getString(CommonStrings.common_report_a_problem)
rule.onNodeWithText(text).assertDoesNotExist() onNodeWithText(text).assertDoesNotExist()
} }
@Test @Test
fun `when success PasswordLogin - the expected callback is invoked and the event is received`() { fun `when success PasswordLogin - the expected callback is invoked and the event is received`() = runAndroidComposeUiTest {
val eventSink = EventsRecorder<OnBoardingEvents>() val eventSink = EventsRecorder<OnBoardingEvents>()
ensureCalledOnce { callback -> ensureCalledOnce { callback ->
rule.setOnboardingView( setOnboardingView(
state = anOnBoardingState( state = anOnBoardingState(
loginMode = AsyncData.Success(LoginMode.PasswordLogin), loginMode = AsyncData.Success(LoginMode.PasswordLogin),
eventSink = eventSink, eventSink = eventSink,
@ -222,11 +220,11 @@ class OnboardingViewTest {
} }
@Test @Test
fun `when success Oidc - the expected callback is invoked and the event is received`() { fun `when success Oidc - the expected callback is invoked and the event is received`() = runAndroidComposeUiTest {
val eventSink = EventsRecorder<OnBoardingEvents>() val eventSink = EventsRecorder<OnBoardingEvents>()
val oAuthDetails = OAuthDetails("aUrl") val oAuthDetails = OAuthDetails("aUrl")
ensureCalledOnceWithParam(oAuthDetails) { callback -> ensureCalledOnceWithParam(oAuthDetails) { callback ->
rule.setOnboardingView( setOnboardingView(
state = anOnBoardingState( state = anOnBoardingState(
loginMode = AsyncData.Success(LoginMode.OAuth(oAuthDetails)), loginMode = AsyncData.Success(LoginMode.OAuth(oAuthDetails)),
eventSink = eventSink, eventSink = eventSink,
@ -238,11 +236,11 @@ class OnboardingViewTest {
} }
@Test @Test
fun `when success AccountCreation - the expected callback is invoked and the event is received`() { fun `when success AccountCreation - the expected callback is invoked and the event is received`() = runAndroidComposeUiTest {
val eventSink = EventsRecorder<OnBoardingEvents>() val eventSink = EventsRecorder<OnBoardingEvents>()
val oAuthDetails = OAuthDetails("aUrl") val oAuthDetails = OAuthDetails("aUrl")
ensureCalledOnceWithParam(oAuthDetails.url) { callback -> ensureCalledOnceWithParam(oAuthDetails.url) { callback ->
rule.setOnboardingView( setOnboardingView(
state = anOnBoardingState( state = anOnBoardingState(
loginMode = AsyncData.Success(LoginMode.AccountCreation("aUrl")), loginMode = AsyncData.Success(LoginMode.AccountCreation("aUrl")),
eventSink = eventSink, eventSink = eventSink,
@ -253,7 +251,7 @@ class OnboardingViewTest {
eventSink.assertSingle(OnBoardingEvents.ClearError) eventSink.assertSingle(OnBoardingEvents.ClearError)
} }
private fun <R : TestRule> AndroidComposeTestRule<R, ComponentActivity>.setOnboardingView( private fun AndroidComposeUiTest<ComponentActivity>.setOnboardingView(
state: OnBoardingState, state: OnBoardingState,
onBackClick: () -> Unit = EnsureNeverCalled(), onBackClick: () -> Unit = EnsureNeverCalled(),
onDeveloperSettingsClick: () -> Unit = EnsureNeverCalled(), onDeveloperSettingsClick: () -> Unit = EnsureNeverCalled(),

View file

@ -6,49 +6,47 @@
* Please see LICENSE files in the repository root for full details. * Please see LICENSE files in the repository root for full details.
*/ */
@file:OptIn(ExperimentalTestApi::class)
package io.element.android.features.login.impl.screens.qrcode.confirmation package io.element.android.features.login.impl.screens.qrcode.confirmation
import androidx.activity.ComponentActivity import androidx.activity.ComponentActivity
import androidx.compose.ui.test.junit4.AndroidComposeTestRule import androidx.compose.ui.test.AndroidComposeUiTest
import androidx.compose.ui.test.junit4.createAndroidComposeRule import androidx.compose.ui.test.ExperimentalTestApi
import androidx.compose.ui.test.v2.runAndroidComposeUiTest
import androidx.test.ext.junit.runners.AndroidJUnit4 import androidx.test.ext.junit.runners.AndroidJUnit4
import io.element.android.libraries.ui.strings.CommonStrings import io.element.android.libraries.ui.strings.CommonStrings
import io.element.android.tests.testutils.clickOn import io.element.android.tests.testutils.clickOn
import io.element.android.tests.testutils.ensureCalledOnce import io.element.android.tests.testutils.ensureCalledOnce
import io.element.android.tests.testutils.pressBackKey import io.element.android.tests.testutils.pressBackKey
import org.junit.Rule
import org.junit.Test import org.junit.Test
import org.junit.rules.TestRule
import org.junit.runner.RunWith import org.junit.runner.RunWith
@RunWith(AndroidJUnit4::class) @RunWith(AndroidJUnit4::class)
class QrCodeConfirmationViewTest { class QrCodeConfirmationViewTest {
@get:Rule
val rule = createAndroidComposeRule<ComponentActivity>()
@Test @Test
fun `on back pressed - calls the expected callback`() { fun `on back pressed - calls the expected callback`() = runAndroidComposeUiTest {
ensureCalledOnce { callback -> ensureCalledOnce { callback ->
rule.setQrCodeConfirmationView( setQrCodeConfirmationView(
step = QrCodeConfirmationStep.DisplayCheckCode("12"), step = QrCodeConfirmationStep.DisplayCheckCode("12"),
onCancel = callback onCancel = callback
) )
rule.pressBackKey() pressBackKey()
} }
} }
@Test @Test
fun `on Cancel button clicked - calls the expected callback`() { fun `on Cancel button clicked - calls the expected callback`() = runAndroidComposeUiTest {
ensureCalledOnce { callback -> ensureCalledOnce { callback ->
rule.setQrCodeConfirmationView( setQrCodeConfirmationView(
step = QrCodeConfirmationStep.DisplayVerificationCode("123456"), step = QrCodeConfirmationStep.DisplayVerificationCode("123456"),
onCancel = callback onCancel = callback
) )
rule.clickOn(CommonStrings.action_cancel) clickOn(CommonStrings.action_cancel)
} }
} }
private fun <R : TestRule> AndroidComposeTestRule<R, ComponentActivity>.setQrCodeConfirmationView( private fun AndroidComposeUiTest<ComponentActivity>.setQrCodeConfirmationView(
step: QrCodeConfirmationStep, step: QrCodeConfirmationStep,
onCancel: () -> Unit onCancel: () -> Unit
) { ) {

View file

@ -6,11 +6,14 @@
* Please see LICENSE files in the repository root for full details. * Please see LICENSE files in the repository root for full details.
*/ */
@file:OptIn(ExperimentalTestApi::class)
package io.element.android.features.login.impl.screens.qrcode.error package io.element.android.features.login.impl.screens.qrcode.error
import androidx.activity.ComponentActivity import androidx.activity.ComponentActivity
import androidx.compose.ui.test.junit4.AndroidComposeTestRule import androidx.compose.ui.test.AndroidComposeUiTest
import androidx.compose.ui.test.junit4.createAndroidComposeRule import androidx.compose.ui.test.ExperimentalTestApi
import androidx.compose.ui.test.v2.runAndroidComposeUiTest
import androidx.test.ext.junit.runners.AndroidJUnit4 import androidx.test.ext.junit.runners.AndroidJUnit4
import io.element.android.features.login.impl.qrcode.QrCodeErrorScreenType import io.element.android.features.login.impl.qrcode.QrCodeErrorScreenType
import io.element.android.libraries.ui.strings.CommonStrings import io.element.android.libraries.ui.strings.CommonStrings
@ -18,47 +21,42 @@ import io.element.android.tests.testutils.EnsureNeverCalled
import io.element.android.tests.testutils.clickOn import io.element.android.tests.testutils.clickOn
import io.element.android.tests.testutils.ensureCalledOnce import io.element.android.tests.testutils.ensureCalledOnce
import io.element.android.tests.testutils.pressBackKey import io.element.android.tests.testutils.pressBackKey
import org.junit.Rule
import org.junit.Test import org.junit.Test
import org.junit.rules.TestRule
import org.junit.runner.RunWith import org.junit.runner.RunWith
@RunWith(AndroidJUnit4::class) @RunWith(AndroidJUnit4::class)
class QrCodeErrorViewTest { class QrCodeErrorViewTest {
@get:Rule
val rule = createAndroidComposeRule<ComponentActivity>()
@Test @Test
fun `on back pressed - calls the onCancel callback`() { fun `on back pressed - calls the onCancel callback`() = runAndroidComposeUiTest {
ensureCalledOnce { callback -> ensureCalledOnce { callback ->
rule.setQrCodeErrorView( setQrCodeErrorView(
onCancel = callback, onCancel = callback,
) )
rule.pressBackKey() pressBackKey()
} }
} }
@Test @Test
fun `on try again button clicked - calls the expected callback`() { fun `on try again button clicked - calls the expected callback`() = runAndroidComposeUiTest {
ensureCalledOnce { callback -> ensureCalledOnce { callback ->
rule.setQrCodeErrorView( setQrCodeErrorView(
onRetry = callback, onRetry = callback,
) )
rule.clickOn(CommonStrings.action_try_again) clickOn(CommonStrings.action_try_again)
} }
} }
@Test @Test
fun `on cancel button clicked - calls the expected callback`() { fun `on cancel button clicked - calls the expected callback`() = runAndroidComposeUiTest {
ensureCalledOnce { callback -> ensureCalledOnce { callback ->
rule.setQrCodeErrorView( setQrCodeErrorView(
onCancel = callback, onCancel = callback,
) )
rule.clickOn(CommonStrings.action_cancel) clickOn(CommonStrings.action_cancel)
} }
} }
private fun <R : TestRule> AndroidComposeTestRule<R, ComponentActivity>.setQrCodeErrorView( private fun AndroidComposeUiTest<ComponentActivity>.setQrCodeErrorView(
onRetry: () -> Unit = EnsureNeverCalled(), onRetry: () -> Unit = EnsureNeverCalled(),
onCancel: () -> Unit = EnsureNeverCalled(), onCancel: () -> Unit = EnsureNeverCalled(),
errorScreenType: QrCodeErrorScreenType = QrCodeErrorScreenType.UnknownError, errorScreenType: QrCodeErrorScreenType = QrCodeErrorScreenType.UnknownError,

View file

@ -6,11 +6,14 @@
* Please see LICENSE files in the repository root for full details. * Please see LICENSE files in the repository root for full details.
*/ */
@file:OptIn(ExperimentalTestApi::class)
package io.element.android.features.login.impl.screens.qrcode.intro package io.element.android.features.login.impl.screens.qrcode.intro
import androidx.activity.ComponentActivity import androidx.activity.ComponentActivity
import androidx.compose.ui.test.junit4.AndroidComposeTestRule import androidx.compose.ui.test.AndroidComposeUiTest
import androidx.compose.ui.test.junit4.createAndroidComposeRule import androidx.compose.ui.test.ExperimentalTestApi
import androidx.compose.ui.test.v2.runAndroidComposeUiTest
import androidx.test.ext.junit.runners.AndroidJUnit4 import androidx.test.ext.junit.runners.AndroidJUnit4
import io.element.android.features.login.impl.R import io.element.android.features.login.impl.R
import io.element.android.tests.testutils.EnsureNeverCalled import io.element.android.tests.testutils.EnsureNeverCalled
@ -19,42 +22,37 @@ import io.element.android.tests.testutils.clickOn
import io.element.android.tests.testutils.ensureCalledOnce import io.element.android.tests.testutils.ensureCalledOnce
import io.element.android.tests.testutils.pressBack import io.element.android.tests.testutils.pressBack
import io.element.android.tests.testutils.pressBackKey import io.element.android.tests.testutils.pressBackKey
import org.junit.Rule
import org.junit.Test import org.junit.Test
import org.junit.rules.TestRule
import org.junit.runner.RunWith import org.junit.runner.RunWith
@RunWith(AndroidJUnit4::class) @RunWith(AndroidJUnit4::class)
class QrCodeIntroViewTest { class QrCodeIntroViewTest {
@get:Rule
val rule = createAndroidComposeRule<ComponentActivity>()
@Test @Test
fun `on back pressed - calls the expected callback`() { fun `on back pressed - calls the expected callback`() = runAndroidComposeUiTest {
ensureCalledOnce { callback -> ensureCalledOnce { callback ->
rule.setQrCodeIntroView( setQrCodeIntroView(
state = aQrCodeIntroState(), state = aQrCodeIntroState(),
onBackClicked = callback onBackClicked = callback
) )
rule.pressBackKey() pressBackKey()
} }
} }
@Test @Test
fun `on back button clicked - calls the expected callback`() { fun `on back button clicked - calls the expected callback`() = runAndroidComposeUiTest {
ensureCalledOnce { callback -> ensureCalledOnce { callback ->
rule.setQrCodeIntroView( setQrCodeIntroView(
state = aQrCodeIntroState(), state = aQrCodeIntroState(),
onBackClicked = callback onBackClicked = callback
) )
rule.pressBack() pressBack()
} }
} }
@Test @Test
fun `when can continue - calls the expected callback`() { fun `when can continue - calls the expected callback`() = runAndroidComposeUiTest {
ensureCalledOnce { callback -> ensureCalledOnce { callback ->
rule.setQrCodeIntroView( setQrCodeIntroView(
state = aQrCodeIntroState(canContinue = true), state = aQrCodeIntroState(canContinue = true),
onContinue = callback onContinue = callback
) )
@ -62,16 +60,16 @@ class QrCodeIntroViewTest {
} }
@Test @Test
fun `on submit button clicked - emits the Continue event`() { fun `on submit button clicked - emits the Continue event`() = runAndroidComposeUiTest {
val eventRecorder = EventsRecorder<QrCodeIntroEvents>() val eventRecorder = EventsRecorder<QrCodeIntroEvents>()
rule.setQrCodeIntroView( setQrCodeIntroView(
state = aQrCodeIntroState(eventSink = eventRecorder), state = aQrCodeIntroState(eventSink = eventRecorder),
) )
rule.clickOn(R.string.screen_qr_code_login_initial_state_button_title) clickOn(R.string.screen_qr_code_login_initial_state_button_title)
eventRecorder.assertSingle(QrCodeIntroEvents.Continue) eventRecorder.assertSingle(QrCodeIntroEvents.Continue)
} }
private fun <R : TestRule> AndroidComposeTestRule<R, ComponentActivity>.setQrCodeIntroView( private fun AndroidComposeUiTest<ComponentActivity>.setQrCodeIntroView(
state: QrCodeIntroState, state: QrCodeIntroState,
onBackClicked: () -> Unit = EnsureNeverCalled(), onBackClicked: () -> Unit = EnsureNeverCalled(),
onContinue: () -> Unit = EnsureNeverCalled(), onContinue: () -> Unit = EnsureNeverCalled(),

View file

@ -6,12 +6,15 @@
* Please see LICENSE files in the repository root for full details. * Please see LICENSE files in the repository root for full details.
*/ */
@file:OptIn(ExperimentalTestApi::class)
package io.element.android.features.login.impl.screens.qrcode.scan package io.element.android.features.login.impl.screens.qrcode.scan
import androidx.activity.ComponentActivity import androidx.activity.ComponentActivity
import androidx.camera.lifecycle.ProcessCameraProvider import androidx.camera.lifecycle.ProcessCameraProvider
import androidx.compose.ui.test.junit4.AndroidComposeTestRule import androidx.compose.ui.test.AndroidComposeUiTest
import androidx.compose.ui.test.junit4.createAndroidComposeRule import androidx.compose.ui.test.ExperimentalTestApi
import androidx.compose.ui.test.v2.runAndroidComposeUiTest
import androidx.test.ext.junit.runners.AndroidJUnit4 import androidx.test.ext.junit.runners.AndroidJUnit4
import androidx.test.platform.app.InstrumentationRegistry import androidx.test.platform.app.InstrumentationRegistry
import io.element.android.libraries.architecture.AsyncAction import io.element.android.libraries.architecture.AsyncAction
@ -24,16 +27,11 @@ import io.element.android.tests.testutils.ensureCalledOnceWithParam
import io.element.android.tests.testutils.pressBackKey import io.element.android.tests.testutils.pressBackKey
import org.junit.After import org.junit.After
import org.junit.Before import org.junit.Before
import org.junit.Rule
import org.junit.Test import org.junit.Test
import org.junit.rules.TestRule
import org.junit.runner.RunWith import org.junit.runner.RunWith
@RunWith(AndroidJUnit4::class) @RunWith(AndroidJUnit4::class)
class QrCodeScanViewTest { class QrCodeScanViewTest {
@get:Rule
val rule = createAndroidComposeRule<ComponentActivity>()
private var provider: ProcessCameraProvider? = null private var provider: ProcessCameraProvider? = null
@Before @Before
@ -48,28 +46,28 @@ class QrCodeScanViewTest {
} }
@Test @Test
fun `on back pressed - calls the expected callback`() { fun `on back pressed - calls the expected callback`() = runAndroidComposeUiTest {
ensureCalledOnce { callback -> ensureCalledOnce { callback ->
rule.setQrCodeScanView( setQrCodeScanView(
state = aQrCodeScanState(), state = aQrCodeScanState(),
onBackClick = callback onBackClick = callback
) )
rule.pressBackKey() pressBackKey()
} }
} }
@Test @Test
fun `on QR code data ready - calls the expected callback`() { fun `on QR code data ready - calls the expected callback`() = runAndroidComposeUiTest {
val data = FakeMatrixQrCodeLoginData() val data = FakeMatrixQrCodeLoginData()
ensureCalledOnceWithParam<MatrixQrCodeLoginData>(data) { callback -> ensureCalledOnceWithParam<MatrixQrCodeLoginData>(data) { callback ->
rule.setQrCodeScanView( setQrCodeScanView(
state = aQrCodeScanState(authenticationAction = AsyncAction.Success(data)), state = aQrCodeScanState(authenticationAction = AsyncAction.Success(data)),
onQrCodeDataReady = callback onQrCodeDataReady = callback
) )
} }
} }
private fun <R : TestRule> AndroidComposeTestRule<R, ComponentActivity>.setQrCodeScanView( private fun AndroidComposeUiTest<ComponentActivity>.setQrCodeScanView(
state: QrCodeScanState, state: QrCodeScanState,
onBackClick: () -> Unit = EnsureNeverCalled(), onBackClick: () -> Unit = EnsureNeverCalled(),
onQrCodeDataReady: (MatrixQrCodeLoginData) -> Unit = EnsureNeverCalledWithParam(), onQrCodeDataReady: (MatrixQrCodeLoginData) -> Unit = EnsureNeverCalledWithParam(),

View file

@ -35,6 +35,7 @@ dependencies {
implementation(projects.libraries.testtags) implementation(projects.libraries.testtags)
implementation(projects.libraries.uiStrings) implementation(projects.libraries.uiStrings)
implementation(projects.libraries.dateformatter.api) implementation(projects.libraries.dateformatter.api)
implementation(projects.libraries.sessionStorage.api)
implementation(projects.libraries.workmanager.api) implementation(projects.libraries.workmanager.api)
api(projects.features.logout.api) api(projects.features.logout.api)

View file

@ -6,11 +6,14 @@
* Please see LICENSE files in the repository root for full details. * Please see LICENSE files in the repository root for full details.
*/ */
@file:OptIn(ExperimentalTestApi::class)
package io.element.android.features.logout.impl package io.element.android.features.logout.impl
import androidx.activity.ComponentActivity import androidx.activity.ComponentActivity
import androidx.compose.ui.test.junit4.AndroidComposeTestRule import androidx.compose.ui.test.AndroidComposeUiTest
import androidx.compose.ui.test.junit4.createAndroidComposeRule import androidx.compose.ui.test.ExperimentalTestApi
import androidx.compose.ui.test.v2.runAndroidComposeUiTest
import androidx.test.ext.junit.runners.AndroidJUnit4 import androidx.test.ext.junit.runners.AndroidJUnit4
import io.element.android.libraries.architecture.AsyncAction import io.element.android.libraries.architecture.AsyncAction
import io.element.android.libraries.testtags.TestTags import io.element.android.libraries.testtags.TestTags
@ -21,97 +24,93 @@ import io.element.android.tests.testutils.clickOn
import io.element.android.tests.testutils.ensureCalledOnce import io.element.android.tests.testutils.ensureCalledOnce
import io.element.android.tests.testutils.pressBack import io.element.android.tests.testutils.pressBack
import io.element.android.tests.testutils.pressTag import io.element.android.tests.testutils.pressTag
import org.junit.Rule
import org.junit.Test import org.junit.Test
import org.junit.rules.TestRule
import org.junit.runner.RunWith import org.junit.runner.RunWith
@RunWith(AndroidJUnit4::class) @RunWith(AndroidJUnit4::class)
class LogoutViewTest { class LogoutViewTest {
@get:Rule val rule = createAndroidComposeRule<ComponentActivity>()
@Test @Test
fun `clicking on logout sends a LogoutEvents`() { fun `clicking on logout sends a LogoutEvents`() = runAndroidComposeUiTest {
val eventsRecorder = EventsRecorder<LogoutEvents>() val eventsRecorder = EventsRecorder<LogoutEvents>()
rule.setLogoutView( setLogoutView(
aLogoutState( aLogoutState(
eventSink = eventsRecorder eventSink = eventsRecorder
), ),
) )
rule.clickOn(CommonStrings.action_signout) clickOn(CommonStrings.action_signout)
eventsRecorder.assertSingle(LogoutEvents.Logout(false)) eventsRecorder.assertSingle(LogoutEvents.Logout(false))
} }
@Test @Test
fun `confirming logout sends a LogoutEvents`() { fun `confirming logout sends a LogoutEvents`() = runAndroidComposeUiTest {
val eventsRecorder = EventsRecorder<LogoutEvents>() val eventsRecorder = EventsRecorder<LogoutEvents>()
rule.setLogoutView( setLogoutView(
aLogoutState( aLogoutState(
logoutAction = AsyncAction.ConfirmingNoParams, logoutAction = AsyncAction.ConfirmingNoParams,
eventSink = eventsRecorder eventSink = eventsRecorder
), ),
) )
rule.pressTag(TestTags.dialogPositive.value) pressTag(TestTags.dialogPositive.value)
eventsRecorder.assertSingle(LogoutEvents.Logout(false)) eventsRecorder.assertSingle(LogoutEvents.Logout(false))
} }
@Test @Test
fun `clicking on back invoke back callback`() { fun `clicking on back invoke back callback`() = runAndroidComposeUiTest {
val eventsRecorder = EventsRecorder<LogoutEvents>(expectEvents = false) val eventsRecorder = EventsRecorder<LogoutEvents>(expectEvents = false)
ensureCalledOnce { callback -> ensureCalledOnce { callback ->
rule.setLogoutView( setLogoutView(
aLogoutState( aLogoutState(
eventSink = eventsRecorder eventSink = eventsRecorder
), ),
onBackClick = callback, onBackClick = callback,
) )
rule.pressBack() pressBack()
} }
} }
@Test @Test
fun `clicking on confirm after error sends a LogoutEvents`() { fun `clicking on confirm after error sends a LogoutEvents`() = runAndroidComposeUiTest {
val eventsRecorder = EventsRecorder<LogoutEvents>() val eventsRecorder = EventsRecorder<LogoutEvents>()
rule.setLogoutView( setLogoutView(
aLogoutState( aLogoutState(
logoutAction = AsyncAction.Failure(Exception("Failed to logout")), logoutAction = AsyncAction.Failure(Exception("Failed to logout")),
eventSink = eventsRecorder eventSink = eventsRecorder
), ),
) )
rule.clickOn(CommonStrings.action_signout_anyway) clickOn(CommonStrings.action_signout_anyway)
eventsRecorder.assertSingle(LogoutEvents.Logout(true)) eventsRecorder.assertSingle(LogoutEvents.Logout(true))
} }
@Test @Test
fun `clicking on cancel after error sends a LogoutEvents`() { fun `clicking on cancel after error sends a LogoutEvents`() = runAndroidComposeUiTest {
val eventsRecorder = EventsRecorder<LogoutEvents>() val eventsRecorder = EventsRecorder<LogoutEvents>()
rule.setLogoutView( setLogoutView(
aLogoutState( aLogoutState(
logoutAction = AsyncAction.Failure(Exception("Failed to logout")), logoutAction = AsyncAction.Failure(Exception("Failed to logout")),
eventSink = eventsRecorder eventSink = eventsRecorder
), ),
) )
rule.clickOn(CommonStrings.action_cancel) clickOn(CommonStrings.action_cancel)
eventsRecorder.assertSingle(LogoutEvents.CloseDialogs) eventsRecorder.assertSingle(LogoutEvents.CloseDialogs)
} }
@Test @Test
fun `last session setting button invoke onChangeRecoveryKeyClicked`() { fun `last session setting button invoke onChangeRecoveryKeyClicked`() = runAndroidComposeUiTest {
val eventsRecorder = EventsRecorder<LogoutEvents>(expectEvents = false) val eventsRecorder = EventsRecorder<LogoutEvents>(expectEvents = false)
ensureCalledOnce { callback -> ensureCalledOnce { callback ->
rule.setLogoutView( setLogoutView(
aLogoutState( aLogoutState(
isLastDevice = true, isLastDevice = true,
eventSink = eventsRecorder eventSink = eventsRecorder
), ),
onChangeRecoveryKeyClick = callback, onChangeRecoveryKeyClick = callback,
) )
rule.clickOn(CommonStrings.common_settings) clickOn(CommonStrings.common_settings)
} }
} }
} }
private fun <R : TestRule> AndroidComposeTestRule<R, ComponentActivity>.setLogoutView( private fun AndroidComposeUiTest<ComponentActivity>.setLogoutView(
state: LogoutState, state: LogoutState,
onChangeRecoveryKeyClick: () -> Unit = EnsureNeverCalled(), onChangeRecoveryKeyClick: () -> Unit = EnsureNeverCalled(),
onBackClick: () -> Unit = EnsureNeverCalled(), onBackClick: () -> Unit = EnsureNeverCalled(),

View file

@ -6,11 +6,14 @@
* Please see LICENSE files in the repository root for full details. * Please see LICENSE files in the repository root for full details.
*/ */
@file:OptIn(ExperimentalTestApi::class)
package io.element.android.features.logout.impl.direct package io.element.android.features.logout.impl.direct
import androidx.activity.ComponentActivity import androidx.activity.ComponentActivity
import androidx.compose.ui.test.junit4.AndroidComposeTestRule import androidx.compose.ui.test.AndroidComposeUiTest
import androidx.compose.ui.test.junit4.createAndroidComposeRule import androidx.compose.ui.test.ExperimentalTestApi
import androidx.compose.ui.test.v2.runAndroidComposeUiTest
import androidx.test.ext.junit.runners.AndroidJUnit4 import androidx.test.ext.junit.runners.AndroidJUnit4
import io.element.android.features.logout.api.direct.DirectLogoutEvents import io.element.android.features.logout.api.direct.DirectLogoutEvents
import io.element.android.features.logout.api.direct.DirectLogoutState import io.element.android.features.logout.api.direct.DirectLogoutState
@ -21,83 +24,79 @@ import io.element.android.tests.testutils.EventsRecorder
import io.element.android.tests.testutils.clickOn import io.element.android.tests.testutils.clickOn
import io.element.android.tests.testutils.pressBackKey import io.element.android.tests.testutils.pressBackKey
import org.junit.Ignore import org.junit.Ignore
import org.junit.Rule
import org.junit.Test import org.junit.Test
import org.junit.rules.TestRule
import org.junit.runner.RunWith import org.junit.runner.RunWith
@RunWith(AndroidJUnit4::class) @RunWith(AndroidJUnit4::class)
class DefaultDirectLogoutViewTest { class DefaultDirectLogoutViewTest {
@get:Rule val rule = createAndroidComposeRule<ComponentActivity>()
@Test @Test
fun `clicking on confirm logout sends expected Event`() { fun `clicking on confirm logout sends expected Event`() = runAndroidComposeUiTest {
val eventsRecorder = EventsRecorder<DirectLogoutEvents>() val eventsRecorder = EventsRecorder<DirectLogoutEvents>()
rule.setDefaultDirectLogoutView( setDefaultDirectLogoutView(
state = aDirectLogoutState( state = aDirectLogoutState(
logoutAction = AsyncAction.ConfirmingNoParams, logoutAction = AsyncAction.ConfirmingNoParams,
eventSink = eventsRecorder, eventSink = eventsRecorder,
) )
) )
rule.clickOn(CommonStrings.action_signout) clickOn(CommonStrings.action_signout)
eventsRecorder.assertSingle(DirectLogoutEvents.Logout(false)) eventsRecorder.assertSingle(DirectLogoutEvents.Logout(false))
} }
@Test @Test
fun `clicking on cancel logout sends expected Event`() { fun `clicking on cancel logout sends expected Event`() = runAndroidComposeUiTest {
val eventsRecorder = EventsRecorder<DirectLogoutEvents>() val eventsRecorder = EventsRecorder<DirectLogoutEvents>()
rule.setDefaultDirectLogoutView( setDefaultDirectLogoutView(
state = aDirectLogoutState( state = aDirectLogoutState(
logoutAction = AsyncAction.ConfirmingNoParams, logoutAction = AsyncAction.ConfirmingNoParams,
eventSink = eventsRecorder, eventSink = eventsRecorder,
) )
) )
rule.clickOn(CommonStrings.action_cancel) clickOn(CommonStrings.action_cancel)
eventsRecorder.assertSingle(DirectLogoutEvents.CloseDialogs) eventsRecorder.assertSingle(DirectLogoutEvents.CloseDialogs)
} }
@Ignore("Pressing back key should dismiss the dialog, and so generate the expected event, but it's not the case.") @Ignore("Pressing back key should dismiss the dialog, and so generate the expected event, but it's not the case.")
@Test @Test
fun `clicking on back invoke back callback`() { fun `clicking on back invoke back callback`() = runAndroidComposeUiTest {
val eventsRecorder = EventsRecorder<DirectLogoutEvents>() val eventsRecorder = EventsRecorder<DirectLogoutEvents>()
rule.setDefaultDirectLogoutView( setDefaultDirectLogoutView(
state = aDirectLogoutState( state = aDirectLogoutState(
logoutAction = AsyncAction.ConfirmingNoParams, logoutAction = AsyncAction.ConfirmingNoParams,
eventSink = eventsRecorder, eventSink = eventsRecorder,
) )
) )
rule.pressBackKey() pressBackKey()
eventsRecorder.assertSingle(DirectLogoutEvents.CloseDialogs) eventsRecorder.assertSingle(DirectLogoutEvents.CloseDialogs)
} }
@Test @Test
fun `clicking on confirm after error sends expected Event`() { fun `clicking on confirm after error sends expected Event`() = runAndroidComposeUiTest {
val eventsRecorder = EventsRecorder<DirectLogoutEvents>() val eventsRecorder = EventsRecorder<DirectLogoutEvents>()
rule.setDefaultDirectLogoutView( setDefaultDirectLogoutView(
state = aDirectLogoutState( state = aDirectLogoutState(
logoutAction = AsyncAction.Failure(Exception("Error")), logoutAction = AsyncAction.Failure(Exception("Error")),
eventSink = eventsRecorder, eventSink = eventsRecorder,
) )
) )
rule.clickOn(CommonStrings.action_signout_anyway) clickOn(CommonStrings.action_signout_anyway)
eventsRecorder.assertSingle(DirectLogoutEvents.Logout(true)) eventsRecorder.assertSingle(DirectLogoutEvents.Logout(true))
} }
@Test @Test
fun `clicking on cancel after error sends expected Event`() { fun `clicking on cancel after error sends expected Event`() = runAndroidComposeUiTest {
val eventsRecorder = EventsRecorder<DirectLogoutEvents>() val eventsRecorder = EventsRecorder<DirectLogoutEvents>()
rule.setDefaultDirectLogoutView( setDefaultDirectLogoutView(
state = aDirectLogoutState( state = aDirectLogoutState(
logoutAction = AsyncAction.Failure(Exception("Error")), logoutAction = AsyncAction.Failure(Exception("Error")),
eventSink = eventsRecorder, eventSink = eventsRecorder,
) )
) )
rule.clickOn(CommonStrings.action_cancel) clickOn(CommonStrings.action_cancel)
eventsRecorder.assertSingle(DirectLogoutEvents.CloseDialogs) eventsRecorder.assertSingle(DirectLogoutEvents.CloseDialogs)
} }
} }
private fun <R : TestRule> AndroidComposeTestRule<R, ComponentActivity>.setDefaultDirectLogoutView( private fun AndroidComposeUiTest<ComponentActivity>.setDefaultDirectLogoutView(
state: DirectLogoutState, state: DirectLogoutState,
) { ) {
setContent { setContent {

View file

@ -24,7 +24,7 @@ import dev.zacsweers.metro.Assisted
import dev.zacsweers.metro.AssistedInject import dev.zacsweers.metro.AssistedInject
import im.vector.app.features.analytics.plan.Interaction import im.vector.app.features.analytics.plan.Interaction
import io.element.android.annotations.ContributesNode import io.element.android.annotations.ContributesNode
import io.element.android.features.call.api.CallType import io.element.android.features.call.api.CallData
import io.element.android.features.call.api.ElementCallEntryPoint import io.element.android.features.call.api.ElementCallEntryPoint
import io.element.android.features.forward.api.ForwardEntryPoint import io.element.android.features.forward.api.ForwardEntryPoint
import io.element.android.features.knockrequests.api.list.KnockRequestsListEntryPoint import io.element.android.features.knockrequests.api.list.KnockRequestsListEntryPoint
@ -277,13 +277,13 @@ class MessagesFlowNode(
} }
override fun navigateToRoomCall(roomId: RoomId, isAudioCall: Boolean) { override fun navigateToRoomCall(roomId: RoomId, isAudioCall: Boolean) {
val callType = CallType.RoomCall( val callData = CallData(
sessionId = sessionId, sessionId = sessionId,
roomId = roomId, roomId = roomId,
isAudioCall = isAudioCall isAudioCall = isAudioCall,
) )
analyticsService.captureInteraction(Interaction.Name.MobileRoomCallButton) analyticsService.captureInteraction(Interaction.Name.MobileRoomCallButton)
elementCallEntryPoint.startCall(callType) elementCallEntryPoint.startCall(callData)
} }
override fun navigateToPinnedMessagesList() { override fun navigateToPinnedMessagesList() {
@ -506,13 +506,13 @@ class MessagesFlowNode(
} }
override fun navigateToRoomCall(roomId: RoomId, isAudioCall: Boolean) { override fun navigateToRoomCall(roomId: RoomId, isAudioCall: Boolean) {
val callType = CallType.RoomCall( val callData = CallData(
sessionId = sessionId, sessionId = sessionId,
roomId = roomId, roomId = roomId,
isAudioCall = isAudioCall isAudioCall = isAudioCall
) )
analyticsService.captureInteraction(Interaction.Name.MobileRoomCallButton) analyticsService.captureInteraction(Interaction.Name.MobileRoomCallButton)
elementCallEntryPoint.startCall(callType) elementCallEntryPoint.startCall(callData)
} }
override fun navigateToThread(threadRootId: ThreadId, focusedEventId: EventId?) { override fun navigateToThread(threadRootId: ThreadId, focusedEventId: EventId?) {

View file

@ -6,13 +6,15 @@
* Please see LICENSE files in the repository root for full details. * Please see LICENSE files in the repository root for full details.
*/ */
@file:OptIn(ExperimentalTestApi::class)
package io.element.android.features.messages.impl package io.element.android.features.messages.impl
import androidx.activity.ComponentActivity import androidx.activity.ComponentActivity
import androidx.compose.runtime.CompositionLocalProvider import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.ui.platform.LocalInspectionMode import androidx.compose.ui.platform.LocalInspectionMode
import androidx.compose.ui.test.junit4.AndroidComposeTestRule import androidx.compose.ui.test.AndroidComposeUiTest
import androidx.compose.ui.test.junit4.createAndroidComposeRule import androidx.compose.ui.test.ExperimentalTestApi
import androidx.compose.ui.test.longClick import androidx.compose.ui.test.longClick
import androidx.compose.ui.test.onAllNodesWithContentDescription import androidx.compose.ui.test.onAllNodesWithContentDescription
import androidx.compose.ui.test.onAllNodesWithTag import androidx.compose.ui.test.onAllNodesWithTag
@ -25,6 +27,7 @@ import androidx.compose.ui.test.onNodeWithText
import androidx.compose.ui.test.performClick import androidx.compose.ui.test.performClick
import androidx.compose.ui.test.performTouchInput import androidx.compose.ui.test.performTouchInput
import androidx.compose.ui.test.swipeRight import androidx.compose.ui.test.swipeRight
import androidx.compose.ui.test.v2.runAndroidComposeUiTest
import androidx.compose.ui.text.AnnotatedString import androidx.compose.ui.text.AnnotatedString
import androidx.test.ext.junit.runners.AndroidJUnit4 import androidx.test.ext.junit.runners.AndroidJUnit4
import io.element.android.emojibasebindings.Emoji import io.element.android.emojibasebindings.Emoji
@ -78,82 +81,78 @@ import io.element.android.tests.testutils.pressBack
import io.element.android.tests.testutils.setSafeContent import io.element.android.tests.testutils.setSafeContent
import kotlinx.collections.immutable.persistentListOf import kotlinx.collections.immutable.persistentListOf
import kotlinx.collections.immutable.persistentMapOf import kotlinx.collections.immutable.persistentMapOf
import org.junit.Rule
import org.junit.Test import org.junit.Test
import org.junit.rules.TestRule
import org.junit.runner.RunWith import org.junit.runner.RunWith
import org.robolectric.annotation.Config import org.robolectric.annotation.Config
import kotlin.time.Duration.Companion.milliseconds import kotlin.time.Duration.Companion.milliseconds
@RunWith(AndroidJUnit4::class) @RunWith(AndroidJUnit4::class)
class MessagesViewTest { class MessagesViewTest {
@get:Rule val rule = createAndroidComposeRule<ComponentActivity>()
@Test @Test
fun `clicking on back invoke expected callback`() { fun `clicking on back invoke expected callback`() = runAndroidComposeUiTest {
val eventsRecorder = EventsRecorder<MessagesEvent>(expectEvents = false) val eventsRecorder = EventsRecorder<MessagesEvent>(expectEvents = false)
val state = aMessagesState( val state = aMessagesState(
eventSink = eventsRecorder eventSink = eventsRecorder
) )
ensureCalledOnce { callback -> ensureCalledOnce { callback ->
rule.setMessagesView( setMessagesView(
state = state, state = state,
onBackClick = callback, onBackClick = callback,
) )
rule.pressBack() pressBack()
} }
} }
@Test @Test
fun `clicking on room name invoke expected callback`() { fun `clicking on room name invoke expected callback`() = runAndroidComposeUiTest {
val eventsRecorder = EventsRecorder<MessagesEvent>(expectEvents = false) val eventsRecorder = EventsRecorder<MessagesEvent>(expectEvents = false)
val state = aMessagesState( val state = aMessagesState(
eventSink = eventsRecorder eventSink = eventsRecorder
) )
ensureCalledOnce { callback -> ensureCalledOnce { callback ->
rule.setMessagesView( setMessagesView(
state = state, state = state,
onRoomDetailsClick = callback, onRoomDetailsClick = callback,
) )
rule.onNodeWithText(state.roomName.orEmpty(), useUnmergedTree = true).performClick() onNodeWithText(state.roomName.orEmpty(), useUnmergedTree = true).performClick()
} }
} }
@Test @Test
fun `clicking on join call invoke expected callback`() { fun `clicking on join call invoke expected callback`() = runAndroidComposeUiTest {
val eventsRecorder = EventsRecorder<MessagesEvent>(expectEvents = false) val eventsRecorder = EventsRecorder<MessagesEvent>(expectEvents = false)
val state = aMessagesState( val state = aMessagesState(
eventSink = eventsRecorder eventSink = eventsRecorder
) )
ensureCalledOnceWithParam(false) { callback -> ensureCalledOnceWithParam(false) { callback ->
rule.setMessagesView( setMessagesView(
state = state, state = state,
onJoinCallClick = callback, onJoinCallClick = callback,
) )
val joinCallContentDescription = rule.activity.getString(CommonStrings.a11y_start_call) val joinCallContentDescription = activity!!.getString(CommonStrings.a11y_start_call)
rule.onNodeWithContentDescription(joinCallContentDescription).performClick() onNodeWithContentDescription(joinCallContentDescription).performClick()
} }
} }
@Test @Test
fun `clicking on join voice call invoke expected callback`() { fun `clicking on join voice call invoke expected callback`() = runAndroidComposeUiTest {
val eventsRecorder = EventsRecorder<MessagesEvent>(expectEvents = false) val eventsRecorder = EventsRecorder<MessagesEvent>(expectEvents = false)
val state = aMessagesState( val state = aMessagesState(
eventSink = eventsRecorder, eventSink = eventsRecorder,
roomCallState = aStandByCallState(isDM = true) roomCallState = aStandByCallState(isDM = true)
) )
ensureCalledOnceWithParam(true) { callback -> ensureCalledOnceWithParam(true) { callback ->
rule.setMessagesView( setMessagesView(
state = state, state = state,
onJoinCallClick = callback, onJoinCallClick = callback,
) )
val joinVoiceCallContentDescription = rule.activity.getString(CommonStrings.a11y_start_voice_call) val joinVoiceCallContentDescription = activity!!.getString(CommonStrings.a11y_start_voice_call)
rule.onNodeWithContentDescription(joinVoiceCallContentDescription).performClick() onNodeWithContentDescription(joinVoiceCallContentDescription).performClick()
} }
} }
@Test @Test
fun `clicking on an Event invoke expected callback`() { fun `clicking on an Event invoke expected callback`() = runAndroidComposeUiTest {
val eventsRecorder = EventsRecorder<MessagesEvent>(expectEvents = false) val eventsRecorder = EventsRecorder<MessagesEvent>(expectEvents = false)
val state = aMessagesState( val state = aMessagesState(
timelineState = aTimelineState( timelineState = aTimelineState(
@ -167,12 +166,12 @@ class MessagesViewTest {
expectedParam2 = timelineItem, expectedParam2 = timelineItem,
result = true, result = true,
) )
rule.setMessagesView( setMessagesView(
state = state, state = state,
onEventClick = callback, onEventClick = callback,
) )
// Cannot perform click on "Text", it's not detected. Use tag instead // Cannot perform click on "Text", it's not detected. Use tag instead
rule.onAllNodesWithTag(TestTags.messageBubble.value).onFirst().performClick() onAllNodesWithTag(TestTags.messageBubble.value).onFirst().performClick()
callback.assertSuccess() callback.assertSuccess()
} }
@ -202,7 +201,7 @@ class MessagesViewTest {
userHasPermissionToRedactOther: Boolean = false, userHasPermissionToRedactOther: Boolean = false,
userHasPermissionToSendReaction: Boolean = false, userHasPermissionToSendReaction: Boolean = false,
userCanPinEvent: Boolean = false, userCanPinEvent: Boolean = false,
) { ) = runAndroidComposeUiTest {
val eventsRecorder = EventsRecorder<ActionListEvent>() val eventsRecorder = EventsRecorder<ActionListEvent>()
val state = aMessagesState( val state = aMessagesState(
actionListState = anActionListState( actionListState = anActionListState(
@ -220,11 +219,11 @@ class MessagesViewTest {
), ),
) )
val timelineItem = state.timelineState.timelineItems.first() as TimelineItem.Event val timelineItem = state.timelineState.timelineItems.first() as TimelineItem.Event
rule.setMessagesView( setMessagesView(
state = state, state = state,
) )
// Cannot perform click on "Text", it's not detected. Use tag instead // Cannot perform click on "Text", it's not detected. Use tag instead
rule.onAllNodesWithTag(TestTags.messageBubble.value).onFirst().performTouchInput { longClick() } onAllNodesWithTag(TestTags.messageBubble.value).onFirst().performTouchInput { longClick() }
eventsRecorder.assertSingle( eventsRecorder.assertSingle(
ActionListEvent.ComputeForMessage( ActionListEvent.ComputeForMessage(
event = timelineItem, event = timelineItem,
@ -235,7 +234,7 @@ class MessagesViewTest {
@Test @Test
@Config(qualifiers = "h1024dp") @Config(qualifiers = "h1024dp")
fun `clicking on a read receipt list emits the expected Event`() { fun `clicking on a read receipt list emits the expected Event`() = runAndroidComposeUiTest {
val eventsRecorder = EventsRecorder<ReadReceiptBottomSheetEvent>() val eventsRecorder = EventsRecorder<ReadReceiptBottomSheetEvent>()
val state = aMessagesState( val state = aMessagesState(
timelineState = aTimelineState( timelineState = aTimelineState(
@ -255,10 +254,10 @@ class MessagesViewTest {
), ),
) )
val timelineItem = state.timelineState.timelineItems.first() as TimelineItem.Event val timelineItem = state.timelineState.timelineItems.first() as TimelineItem.Event
rule.setMessagesView( setMessagesView(
state = state, state = state,
) )
rule.onNodeWithTag(TestTags.messageReadReceipts.value, useUnmergedTree = true).performClick() onNodeWithTag(TestTags.messageReadReceipts.value, useUnmergedTree = true).performClick()
eventsRecorder.assertSingle(ReadReceiptBottomSheetEvent.EventSelected(timelineItem)) eventsRecorder.assertSingle(ReadReceiptBottomSheetEvent.EventSelected(timelineItem))
} }
@ -272,7 +271,7 @@ class MessagesViewTest {
swipeTest(userHasPermissionToSendMessage = false) swipeTest(userHasPermissionToSendMessage = false)
} }
private fun swipeTest(userHasPermissionToSendMessage: Boolean) { private fun swipeTest(userHasPermissionToSendMessage: Boolean) = runAndroidComposeUiTest {
val eventsRecorder = EventsRecorder<MessagesEvent>() val eventsRecorder = EventsRecorder<MessagesEvent>()
val canBeRepliedEvent = aTimelineItemEvent(canBeRepliedTo = true) val canBeRepliedEvent = aTimelineItemEvent(canBeRepliedTo = true)
val cannotBeRepliedEvent = aTimelineItemEvent(canBeRepliedTo = false) val cannotBeRepliedEvent = aTimelineItemEvent(canBeRepliedTo = false)
@ -285,10 +284,10 @@ class MessagesViewTest {
), ),
eventSink = eventsRecorder, eventSink = eventsRecorder,
) )
rule.setMessagesView( setMessagesView(
state = state, state = state,
) )
rule.onAllNodesWithTag(TestTags.messageBubble.value).apply { onAllNodesWithTag(TestTags.messageBubble.value).apply {
onFirst().performTouchInput { swipeRight(endX = 200f) } onFirst().performTouchInput { swipeRight(endX = 200f) }
onLast().performTouchInput { swipeRight(endX = 200f) } onLast().performTouchInput { swipeRight(endX = 200f) }
} }
@ -300,7 +299,7 @@ class MessagesViewTest {
} }
@Test @Test
fun `clicking on send location invoke expected callback`() { fun `clicking on send location invoke expected callback`() = runAndroidComposeUiTest {
val eventsRecorder = EventsRecorder<MessagesEvent>(expectEvents = false) val eventsRecorder = EventsRecorder<MessagesEvent>(expectEvents = false)
val state = aMessagesState( val state = aMessagesState(
composerState = aMessageComposerState( composerState = aMessageComposerState(
@ -309,16 +308,16 @@ class MessagesViewTest {
eventSink = eventsRecorder eventSink = eventsRecorder
) )
ensureCalledOnce { callback -> ensureCalledOnce { callback ->
rule.setMessagesView( setMessagesView(
state = state, state = state,
onSendLocationClick = callback, onSendLocationClick = callback,
) )
rule.clickOn(R.string.screen_room_attachment_source_location) clickOn(R.string.screen_room_attachment_source_location)
} }
} }
@Test @Test
fun `clicking on create poll invoke expected callback`() { fun `clicking on create poll invoke expected callback`() = runAndroidComposeUiTest {
val eventsRecorder = EventsRecorder<MessagesEvent>(expectEvents = false) val eventsRecorder = EventsRecorder<MessagesEvent>(expectEvents = false)
val state = aMessagesState( val state = aMessagesState(
composerState = aMessageComposerState( composerState = aMessageComposerState(
@ -327,25 +326,25 @@ class MessagesViewTest {
eventSink = eventsRecorder eventSink = eventsRecorder
) )
ensureCalledOnce { callback -> ensureCalledOnce { callback ->
rule.setMessagesView( setMessagesView(
state = state, state = state,
onCreatePollClick = callback, onCreatePollClick = callback,
) )
// Then click on the poll action // Then click on the poll action
rule.clickOn(R.string.screen_room_attachment_source_poll) clickOn(R.string.screen_room_attachment_source_poll)
} }
} }
@Test @Test
@Config(qualifiers = "h1024dp") @Config(qualifiers = "h1024dp")
fun `clicking on the avatar of the sender of an Event emits the expected event`() { fun `clicking on the avatar of the sender of an Event emits the expected event`() = runAndroidComposeUiTest {
val eventsRecorder = EventsRecorder<MessagesEvent>() val eventsRecorder = EventsRecorder<MessagesEvent>()
val state = aMessagesState( val state = aMessagesState(
eventSink = eventsRecorder eventSink = eventsRecorder
) )
val timelineEvent = state.timelineState.timelineItems.filterIsInstance<TimelineItem.Event>().first() val timelineEvent = state.timelineState.timelineItems.filterIsInstance<TimelineItem.Event>().first()
rule.setMessagesView(state = state) setMessagesView(state = state)
rule.onNodeWithTag(TestTags.timelineItemSenderAvatar.value, useUnmergedTree = true).performClick() onNodeWithTag(TestTags.timelineItemSenderAvatar.value, useUnmergedTree = true).performClick()
eventsRecorder.assertSingle( eventsRecorder.assertSingle(
MessagesEvent.OnUserClicked( MessagesEvent.OnUserClicked(
MatrixUser( MatrixUser(
@ -359,12 +358,12 @@ class MessagesViewTest {
@Test @Test
@Config(qualifiers = "h1024dp") @Config(qualifiers = "h1024dp")
fun `clicking on the display name of the sender of an Event emits expected event`() { fun `clicking on the display name of the sender of an Event emits expected event`() = runAndroidComposeUiTest {
val eventsRecorder = EventsRecorder<MessagesEvent>() val eventsRecorder = EventsRecorder<MessagesEvent>()
val state = aMessagesState(eventSink = eventsRecorder) val state = aMessagesState(eventSink = eventsRecorder)
val timelineEvent = state.timelineState.timelineItems.filterIsInstance<TimelineItem.Event>().first() val timelineEvent = state.timelineState.timelineItems.filterIsInstance<TimelineItem.Event>().first()
rule.setMessagesView(state = state) setMessagesView(state = state)
rule.onNodeWithTag(TestTags.timelineItemSenderAvatar.value, useUnmergedTree = true).performClick() onNodeWithTag(TestTags.timelineItemSenderAvatar.value, useUnmergedTree = true).performClick()
eventsRecorder.assertSingle( eventsRecorder.assertSingle(
MessagesEvent.OnUserClicked( MessagesEvent.OnUserClicked(
MatrixUser( MatrixUser(
@ -377,7 +376,7 @@ class MessagesViewTest {
} }
@Test @Test
fun `selecting a action on a message emits the expected Event`() { fun `selecting a action on a message emits the expected Event`() = runAndroidComposeUiTest {
val eventsRecorder = EventsRecorder<MessagesEvent>() val eventsRecorder = EventsRecorder<MessagesEvent>()
val state = aMessagesState( val state = aMessagesState(
eventSink = eventsRecorder eventSink = eventsRecorder
@ -395,17 +394,17 @@ class MessagesViewTest {
) )
), ),
) )
rule.setMessagesView( setMessagesView(
state = stateWithMessageAction, state = stateWithMessageAction,
) )
rule.clickOn(CommonStrings.action_edit) clickOn(CommonStrings.action_edit)
// Give time for the close animation to complete // Give time for the close animation to complete
rule.mainClock.advanceTimeBy(milliseconds = 1_000) mainClock.advanceTimeBy(milliseconds = 1_000)
eventsRecorder.assertSingle(MessagesEvent.HandleAction(TimelineItemAction.Edit, timelineItem)) eventsRecorder.assertSingle(MessagesEvent.HandleAction(TimelineItemAction.Edit, timelineItem))
} }
@Test @Test
fun `clicking on a reaction emits the expected Event`() { fun `clicking on a reaction emits the expected Event`() = runAndroidComposeUiTest {
val eventsRecorder = EventsRecorder<MessagesEvent>() val eventsRecorder = EventsRecorder<MessagesEvent>()
val state = aMessagesState( val state = aMessagesState(
timelineState = aTimelineState( timelineState = aTimelineState(
@ -414,10 +413,10 @@ class MessagesViewTest {
eventSink = eventsRecorder, eventSink = eventsRecorder,
) )
val timelineItem = state.timelineState.timelineItems.first() as TimelineItem.Event val timelineItem = state.timelineState.timelineItems.first() as TimelineItem.Event
rule.setMessagesView( setMessagesView(
state = state, state = state,
) )
rule.onAllNodesWithText( onAllNodesWithText(
text = "👍️", text = "👍️",
useUnmergedTree = true, useUnmergedTree = true,
).onFirst().performClick() ).onFirst().performClick()
@ -425,7 +424,7 @@ class MessagesViewTest {
} }
@Test @Test
fun `long clicking on a reaction emits the expected Event`() { fun `long clicking on a reaction emits the expected Event`() = runAndroidComposeUiTest {
val eventsRecorder = EventsRecorder<ReactionSummaryEvent>() val eventsRecorder = EventsRecorder<ReactionSummaryEvent>()
val state = aMessagesState( val state = aMessagesState(
timelineState = aTimelineState( timelineState = aTimelineState(
@ -437,10 +436,10 @@ class MessagesViewTest {
), ),
) )
val timelineItem = state.timelineState.timelineItems.first() as TimelineItem.Event val timelineItem = state.timelineState.timelineItems.first() as TimelineItem.Event
rule.setMessagesView( setMessagesView(
state = state, state = state,
) )
rule.onAllNodesWithText( onAllNodesWithText(
text = "👍️", text = "👍️",
useUnmergedTree = true, useUnmergedTree = true,
).onFirst().performTouchInput { longClick() } ).onFirst().performTouchInput { longClick() }
@ -448,7 +447,7 @@ class MessagesViewTest {
} }
@Test @Test
fun `clicking on more reaction emits the expected Event`() { fun `clicking on more reaction emits the expected Event`() = runAndroidComposeUiTest {
val eventsRecorder = EventsRecorder<CustomReactionEvent>() val eventsRecorder = EventsRecorder<CustomReactionEvent>()
val state = aMessagesState( val state = aMessagesState(
timelineState = aTimelineState( timelineState = aTimelineState(
@ -459,16 +458,16 @@ class MessagesViewTest {
), ),
) )
val timelineItem = state.timelineState.timelineItems.first() as TimelineItem.Event val timelineItem = state.timelineState.timelineItems.first() as TimelineItem.Event
rule.setMessagesView( setMessagesView(
state = state, state = state,
) )
val moreReactionContentDescription = rule.activity.getString(R.string.screen_room_timeline_add_reaction) val moreReactionContentDescription = activity!!.getString(R.string.screen_room_timeline_add_reaction)
rule.onAllNodesWithContentDescription(moreReactionContentDescription).onFirst().performClick() onAllNodesWithContentDescription(moreReactionContentDescription).onFirst().performClick()
eventsRecorder.assertSingle(CustomReactionEvent.ShowCustomReactionSheet(timelineItem)) eventsRecorder.assertSingle(CustomReactionEvent.ShowCustomReactionSheet(timelineItem))
} }
@Test @Test
fun `clicking on more reaction from action list emits the expected Event`() { fun `clicking on more reaction from action list emits the expected Event`() = runAndroidComposeUiTest {
val eventsRecorder = EventsRecorder<CustomReactionEvent>() val eventsRecorder = EventsRecorder<CustomReactionEvent>()
val state = aMessagesState( val state = aMessagesState(
timelineState = aTimelineState( timelineState = aTimelineState(
@ -491,18 +490,18 @@ class MessagesViewTest {
eventSink = eventsRecorder eventSink = eventsRecorder
), ),
) )
rule.setMessagesView( setMessagesView(
state = stateWithActionListState, state = stateWithActionListState,
) )
val moreReactionContentDescription = rule.activity.getString(CommonStrings.a11y_react_with_other_emojis) val moreReactionContentDescription = activity!!.getString(CommonStrings.a11y_react_with_other_emojis)
rule.onNodeWithContentDescription(moreReactionContentDescription).performClick() onNodeWithContentDescription(moreReactionContentDescription).performClick()
// Give time for the close animation to complete // Give time for the close animation to complete
rule.mainClock.advanceTimeBy(milliseconds = 1_000) mainClock.advanceTimeBy(milliseconds = 1_000)
eventsRecorder.assertSingle(CustomReactionEvent.ShowCustomReactionSheet(timelineItem)) eventsRecorder.assertSingle(CustomReactionEvent.ShowCustomReactionSheet(timelineItem))
} }
@Test @Test
fun `clicking on verified user send failure from action list emits the expected Event`() { fun `clicking on verified user send failure from action list emits the expected Event`() = runAndroidComposeUiTest {
val eventsRecorder = EventsRecorder<TimelineEvent>() val eventsRecorder = EventsRecorder<TimelineEvent>()
val state = aMessagesState() val state = aMessagesState()
val timelineItem = state.timelineState.timelineItems.first() as TimelineItem.Event val timelineItem = state.timelineState.timelineItems.first() as TimelineItem.Event
@ -519,21 +518,21 @@ class MessagesViewTest {
), ),
timelineState = aTimelineState(eventSink = eventsRecorder) timelineState = aTimelineState(eventSink = eventsRecorder)
) )
rule.setMessagesView( setMessagesView(
state = stateWithActionListState, state = stateWithActionListState,
) )
// Clear initial 'LoadMore' event emitted when setting the state // Clear initial 'LoadMore' event emitted when setting the state
eventsRecorder.clear() eventsRecorder.clear()
val verifiedUserSendFailure = rule.activity.getString(CommonStrings.screen_timeline_item_menu_send_failure_changed_identity, "Alice") val verifiedUserSendFailure = activity!!.getString(CommonStrings.screen_timeline_item_menu_send_failure_changed_identity, "Alice")
rule.onNodeWithText(verifiedUserSendFailure).performClick() onNodeWithText(verifiedUserSendFailure).performClick()
// Give time for the close animation to complete // Give time for the close animation to complete
rule.mainClock.advanceTimeBy(milliseconds = 1_000) mainClock.advanceTimeBy(milliseconds = 1_000)
eventsRecorder.assertSingle(TimelineEvent.ComputeVerifiedUserSendFailure(timelineItem)) eventsRecorder.assertSingle(TimelineEvent.ComputeVerifiedUserSendFailure(timelineItem))
} }
@Test @Test
fun `clicking on a custom emoji emits the expected Events`() { fun `clicking on a custom emoji emits the expected Events`() = runAndroidComposeUiTest {
val aUnicode = "🙈" val aUnicode = "🙈"
val customReactionStateEventsRecorder = EventsRecorder<CustomReactionEvent>() val customReactionStateEventsRecorder = EventsRecorder<CustomReactionEvent>()
val eventsRecorder = EventsRecorder<MessagesEvent>() val eventsRecorder = EventsRecorder<MessagesEvent>()
@ -563,18 +562,18 @@ class MessagesViewTest {
eventSink = customReactionStateEventsRecorder eventSink = customReactionStateEventsRecorder
), ),
) )
rule.setMessagesView( setMessagesView(
state = stateWithCustomReactionState, state = stateWithCustomReactionState,
) )
rule.onNodeWithText(aUnicode, useUnmergedTree = true).performClick() onNodeWithText(aUnicode, useUnmergedTree = true).performClick()
// Give time for the close animation to complete // Give time for the close animation to complete
rule.mainClock.advanceTimeBy(milliseconds = 1_000) mainClock.advanceTimeBy(milliseconds = 1_000)
customReactionStateEventsRecorder.assertSingle(CustomReactionEvent.DismissCustomReactionSheet) customReactionStateEventsRecorder.assertSingle(CustomReactionEvent.DismissCustomReactionSheet)
eventsRecorder.assertSingle(MessagesEvent.ToggleReaction(aUnicode, timelineItem.eventOrTransactionId)) eventsRecorder.assertSingle(MessagesEvent.ToggleReaction(aUnicode, timelineItem.eventOrTransactionId))
} }
@Test @Test
fun `clicking on pinned messages banner emits the expected Event`() { fun `clicking on pinned messages banner emits the expected Event`() = runAndroidComposeUiTest {
val eventsRecorder = EventsRecorder<TimelineEvent>() val eventsRecorder = EventsRecorder<TimelineEvent>()
val state = aMessagesState( val state = aMessagesState(
timelineState = aTimelineState(eventSink = eventsRecorder), timelineState = aTimelineState(eventSink = eventsRecorder),
@ -587,16 +586,16 @@ class MessagesViewTest {
), ),
), ),
) )
rule.setMessagesView(state = state) setMessagesView(state = state)
// Clear initial 'LoadMore' event emitted when setting the state // Clear initial 'LoadMore' event emitted when setting the state
eventsRecorder.clear() eventsRecorder.clear()
rule.onNodeWithText("This is a pinned message").performClick() onNodeWithText("This is a pinned message").performClick()
eventsRecorder.assertSingle(TimelineEvent.FocusOnEvent(AN_EVENT_ID, debounce = FOCUS_ON_PINNED_EVENT_DEBOUNCE_DURATION_IN_MILLIS.milliseconds)) eventsRecorder.assertSingle(TimelineEvent.FocusOnEvent(AN_EVENT_ID, debounce = FOCUS_ON_PINNED_EVENT_DEBOUNCE_DURATION_IN_MILLIS.milliseconds))
} }
@Test @Test
fun `clicking on successor room button emits expected event`() { fun `clicking on successor room button emits expected event`() = runAndroidComposeUiTest {
val eventsRecorder = EventsRecorder<TimelineEvent>() val eventsRecorder = EventsRecorder<TimelineEvent>()
val successorRoomId = RoomId("!successor:server.org") val successorRoomId = RoomId("!successor:server.org")
val state = aMessagesState( val state = aMessagesState(
@ -606,18 +605,18 @@ class MessagesViewTest {
), ),
timelineState = aTimelineState(eventSink = eventsRecorder) timelineState = aTimelineState(eventSink = eventsRecorder)
) )
rule.setMessagesView(state = state) setMessagesView(state = state)
// Clear initial 'LoadMore' event emitted when setting the state // Clear initial 'LoadMore' event emitted when setting the state
eventsRecorder.clear() eventsRecorder.clear()
val text = rule.activity.getString(R.string.screen_room_timeline_tombstoned_room_action) val text = activity!!.getString(R.string.screen_room_timeline_tombstoned_room_action)
// The bottomsheet subcompose seems to make the node to appear twice // The bottomsheet subcompose seems to make the node to appear twice
rule.onAllNodesWithText(text).onFirst().performClick() onAllNodesWithText(text).onFirst().performClick()
eventsRecorder.assertSingle(TimelineEvent.NavigateToPredecessorOrSuccessorRoom(successorRoomId)) eventsRecorder.assertSingle(TimelineEvent.NavigateToPredecessorOrSuccessorRoom(successorRoomId))
} }
@Test @Test
fun `clicking on threads list button calls the expected function`() { fun `clicking on threads list button calls the expected function`() = runAndroidComposeUiTest {
val state = aMessagesState( val state = aMessagesState(
threads = MessagesState.Threads( threads = MessagesState.Threads(
hasThreads = true, hasThreads = true,
@ -625,28 +624,28 @@ class MessagesViewTest {
) )
) )
val onThreadsListClicked = lambdaRecorder<Unit> {} val onThreadsListClicked = lambdaRecorder<Unit> {}
rule.setMessagesView( setMessagesView(
state = state, state = state,
onThreadsListClicked = onThreadsListClicked, onThreadsListClicked = onThreadsListClicked,
) )
rule.onNodeWithContentDescription("Threads").performClick() onNodeWithContentDescription("Threads").performClick()
onThreadsListClicked.assertions().isCalledOnce() onThreadsListClicked.assertions().isCalledOnce()
} }
@Test @Test
fun `no banner shown when there is no successor room`() { fun `no banner shown when there is no successor room`() = runAndroidComposeUiTest {
val eventsRecorder = EventsRecorder<MessagesEvent>(expectEvents = false) val eventsRecorder = EventsRecorder<MessagesEvent>(expectEvents = false)
val state = aMessagesState( val state = aMessagesState(
successorRoom = null, successorRoom = null,
eventSink = eventsRecorder eventSink = eventsRecorder
) )
rule.setMessagesView(state = state) setMessagesView(state = state)
rule.assertNoNodeWithText(R.string.screen_room_timeline_tombstoned_room_message) assertNoNodeWithText(R.string.screen_room_timeline_tombstoned_room_message)
rule.assertNoNodeWithText(R.string.screen_room_timeline_tombstoned_room_action) assertNoNodeWithText(R.string.screen_room_timeline_tombstoned_room_action)
} }
} }
private fun <R : TestRule> AndroidComposeTestRule<R, ComponentActivity>.setMessagesView( private fun AndroidComposeUiTest<ComponentActivity>.setMessagesView(
state: MessagesState, state: MessagesState,
onBackClick: () -> Unit = EnsureNeverCalled(), onBackClick: () -> Unit = EnsureNeverCalled(),
onRoomDetailsClick: () -> Unit = EnsureNeverCalled(), onRoomDetailsClick: () -> Unit = EnsureNeverCalled(),

View file

@ -6,12 +6,15 @@
* Please see LICENSE files in the repository root for full details. * Please see LICENSE files in the repository root for full details.
*/ */
@file:OptIn(ExperimentalTestApi::class)
package io.element.android.features.messages.impl.crypto.identity package io.element.android.features.messages.impl.crypto.identity
import androidx.activity.ComponentActivity import androidx.activity.ComponentActivity
import androidx.compose.ui.test.junit4.AndroidComposeTestRule import androidx.compose.ui.test.AndroidComposeUiTest
import androidx.compose.ui.test.junit4.createAndroidComposeRule import androidx.compose.ui.test.ExperimentalTestApi
import androidx.compose.ui.test.onNodeWithText import androidx.compose.ui.test.onNodeWithText
import androidx.compose.ui.test.v2.runAndroidComposeUiTest
import androidx.test.ext.junit.runners.AndroidJUnit4 import androidx.test.ext.junit.runners.AndroidJUnit4
import io.element.android.libraries.designsystem.components.avatar.anAvatarData import io.element.android.libraries.designsystem.components.avatar.anAvatarData
import io.element.android.libraries.matrix.api.core.UserId import io.element.android.libraries.matrix.api.core.UserId
@ -21,19 +24,15 @@ import io.element.android.libraries.matrix.ui.room.RoomMemberIdentityStateChange
import io.element.android.libraries.ui.strings.CommonStrings import io.element.android.libraries.ui.strings.CommonStrings
import io.element.android.tests.testutils.EventsRecorder import io.element.android.tests.testutils.EventsRecorder
import io.element.android.tests.testutils.clickOn import io.element.android.tests.testutils.clickOn
import org.junit.Rule
import org.junit.Test import org.junit.Test
import org.junit.rules.TestRule
import org.junit.runner.RunWith import org.junit.runner.RunWith
@RunWith(AndroidJUnit4::class) @RunWith(AndroidJUnit4::class)
class IdentityChangeStateViewTest { class IdentityChangeStateViewTest {
@get:Rule val rule = createAndroidComposeRule<ComponentActivity>()
@Test @Test
fun `show and resolve pin violation`() { fun `show and resolve pin violation`() = runAndroidComposeUiTest {
val eventsRecorder = EventsRecorder<IdentityChangeEvent>() val eventsRecorder = EventsRecorder<IdentityChangeEvent>()
rule.setIdentityChangeStateView( setIdentityChangeStateView(
state = anIdentityChangeState( state = anIdentityChangeState(
listOf( listOf(
RoomMemberIdentityStateChange( RoomMemberIdentityStateChange(
@ -45,18 +44,18 @@ class IdentityChangeStateViewTest {
), ),
) )
rule.onNodeWithText("identity was reset", substring = true).assertExists("should display pin violation warning") onNodeWithText("identity was reset", substring = true).assertExists("should display pin violation warning")
rule.onNodeWithText("@alice:localhost", substring = true).assertExists("should display user mxid") onNodeWithText("@alice:localhost", substring = true).assertExists("should display user mxid")
rule.onNodeWithText("Alice", substring = true).assertExists("should display user displayname") onNodeWithText("Alice", substring = true).assertExists("should display user displayname")
rule.clickOn(res = CommonStrings.action_dismiss) clickOn(res = CommonStrings.action_dismiss)
eventsRecorder.assertSingle(IdentityChangeEvent.PinIdentity(UserId("@alice:localhost"))) eventsRecorder.assertSingle(IdentityChangeEvent.PinIdentity(UserId("@alice:localhost")))
} }
@Test @Test
fun `show and resolve verification violation`() { fun `show and resolve verification violation`() = runAndroidComposeUiTest {
val eventsRecorder = EventsRecorder<IdentityChangeEvent>() val eventsRecorder = EventsRecorder<IdentityChangeEvent>()
rule.setIdentityChangeStateView( setIdentityChangeStateView(
state = anIdentityChangeState( state = anIdentityChangeState(
listOf( listOf(
RoomMemberIdentityStateChange( RoomMemberIdentityStateChange(
@ -68,17 +67,17 @@ class IdentityChangeStateViewTest {
), ),
) )
rule.onNodeWithText("identity was reset", substring = true).assertExists("should display verification violation warning") onNodeWithText("identity was reset", substring = true).assertExists("should display verification violation warning")
rule.onNodeWithText("@alice:localhost", substring = true).assertExists("should display user mxid") onNodeWithText("@alice:localhost", substring = true).assertExists("should display user mxid")
rule.onNodeWithText("Alice", substring = true).assertExists("should display user displayname") onNodeWithText("Alice", substring = true).assertExists("should display user displayname")
rule.clickOn(res = CommonStrings.crypto_identity_change_withdraw_verification_action) clickOn(res = CommonStrings.crypto_identity_change_withdraw_verification_action)
eventsRecorder.assertSingle(IdentityChangeEvent.WithdrawVerification(UserId("@alice:localhost"))) eventsRecorder.assertSingle(IdentityChangeEvent.WithdrawVerification(UserId("@alice:localhost")))
} }
@Test @Test
fun `Should not show any banner if no violations`() { fun `Should not show any banner if no violations`() = runAndroidComposeUiTest {
rule.setIdentityChangeStateView( setIdentityChangeStateView(
state = anIdentityChangeState( state = anIdentityChangeState(
listOf( listOf(
RoomMemberIdentityStateChange( RoomMemberIdentityStateChange(
@ -93,10 +92,10 @@ class IdentityChangeStateViewTest {
), ),
) )
rule.onNodeWithText("identity was reset", substring = true).assertDoesNotExist() onNodeWithText("identity was reset", substring = true).assertDoesNotExist()
} }
private fun <R : TestRule> AndroidComposeTestRule<R, ComponentActivity>.setIdentityChangeStateView( private fun AndroidComposeUiTest<ComponentActivity>.setIdentityChangeStateView(
state: IdentityChangeState, state: IdentityChangeState,
) { ) {
setContent { setContent {

View file

@ -6,54 +6,53 @@
* Please see LICENSE files in the repository root for full details. * Please see LICENSE files in the repository root for full details.
*/ */
@file:OptIn(ExperimentalTestApi::class)
package io.element.android.features.messages.impl.crypto.sendfailure.resolve package io.element.android.features.messages.impl.crypto.sendfailure.resolve
import androidx.activity.ComponentActivity import androidx.activity.ComponentActivity
import androidx.compose.ui.test.junit4.AndroidComposeTestRule import androidx.compose.ui.test.AndroidComposeUiTest
import androidx.compose.ui.test.junit4.createAndroidComposeRule import androidx.compose.ui.test.ExperimentalTestApi
import androidx.compose.ui.test.v2.runAndroidComposeUiTest
import androidx.test.ext.junit.runners.AndroidJUnit4 import androidx.test.ext.junit.runners.AndroidJUnit4
import io.element.android.libraries.ui.strings.CommonStrings import io.element.android.libraries.ui.strings.CommonStrings
import io.element.android.tests.testutils.EventsRecorder import io.element.android.tests.testutils.EventsRecorder
import io.element.android.tests.testutils.clickOn import io.element.android.tests.testutils.clickOn
import io.element.android.tests.testutils.setSafeContent import io.element.android.tests.testutils.setSafeContent
import org.junit.Rule
import org.junit.Test import org.junit.Test
import org.junit.rules.TestRule
import org.junit.runner.RunWith import org.junit.runner.RunWith
@RunWith(AndroidJUnit4::class) @RunWith(AndroidJUnit4::class)
class ResolveVerifiedUserSendFailureViewTest { class ResolveVerifiedUserSendFailureViewTest {
@get:Rule val rule = createAndroidComposeRule<ComponentActivity>()
@Test @Test
fun `clicking on resolve and resend emit the expected event`() { fun `clicking on resolve and resend emit the expected event`() = runAndroidComposeUiTest {
val eventsRecorder = EventsRecorder<ResolveVerifiedUserSendFailureEvent>() val eventsRecorder = EventsRecorder<ResolveVerifiedUserSendFailureEvent>()
rule.setResolveVerifiedUserSendFailureView( setResolveVerifiedUserSendFailureView(
state = aResolveVerifiedUserSendFailureState( state = aResolveVerifiedUserSendFailureState(
verifiedUserSendFailure = aChangedIdentitySendFailure(), verifiedUserSendFailure = aChangedIdentitySendFailure(),
eventSink = eventsRecorder, eventSink = eventsRecorder,
), ),
) )
rule.clickOn(res = CommonStrings.screen_resolve_send_failure_changed_identity_primary_button_title) clickOn(res = CommonStrings.screen_resolve_send_failure_changed_identity_primary_button_title)
eventsRecorder.assertSingle(ResolveVerifiedUserSendFailureEvent.ResolveAndResend) eventsRecorder.assertSingle(ResolveVerifiedUserSendFailureEvent.ResolveAndResend)
} }
@Test @Test
fun `clicking on retry emit the expected event`() { fun `clicking on retry emit the expected event`() = runAndroidComposeUiTest {
val eventsRecorder = EventsRecorder<ResolveVerifiedUserSendFailureEvent>() val eventsRecorder = EventsRecorder<ResolveVerifiedUserSendFailureEvent>()
rule.setResolveVerifiedUserSendFailureView( setResolveVerifiedUserSendFailureView(
state = aResolveVerifiedUserSendFailureState( state = aResolveVerifiedUserSendFailureState(
verifiedUserSendFailure = aChangedIdentitySendFailure(), verifiedUserSendFailure = aChangedIdentitySendFailure(),
eventSink = eventsRecorder, eventSink = eventsRecorder,
), ),
) )
rule.clickOn(res = CommonStrings.action_retry) clickOn(res = CommonStrings.action_retry)
eventsRecorder.assertSingle(ResolveVerifiedUserSendFailureEvent.Retry) eventsRecorder.assertSingle(ResolveVerifiedUserSendFailureEvent.Retry)
} }
private fun <R : TestRule> AndroidComposeTestRule<R, ComponentActivity>.setResolveVerifiedUserSendFailureView( private fun AndroidComposeUiTest<ComponentActivity>.setResolveVerifiedUserSendFailureView(
state: ResolveVerifiedUserSendFailureState, state: ResolveVerifiedUserSendFailureState,
) { ) {
setSafeContent { setSafeContent {

View file

@ -6,11 +6,14 @@
* Please see LICENSE files in the repository root for full details. * Please see LICENSE files in the repository root for full details.
*/ */
@file:OptIn(ExperimentalTestApi::class)
package io.element.android.features.messages.impl.link package io.element.android.features.messages.impl.link
import androidx.activity.ComponentActivity import androidx.activity.ComponentActivity
import androidx.compose.ui.test.junit4.AndroidComposeTestRule import androidx.compose.ui.test.AndroidComposeUiTest
import androidx.compose.ui.test.junit4.createAndroidComposeRule import androidx.compose.ui.test.ExperimentalTestApi
import androidx.compose.ui.test.v2.runAndroidComposeUiTest
import androidx.test.ext.junit.runners.AndroidJUnit4 import androidx.test.ext.junit.runners.AndroidJUnit4
import io.element.android.libraries.architecture.AsyncAction import io.element.android.libraries.architecture.AsyncAction
import io.element.android.libraries.ui.strings.CommonStrings import io.element.android.libraries.ui.strings.CommonStrings
@ -19,51 +22,46 @@ import io.element.android.tests.testutils.EventsRecorder
import io.element.android.tests.testutils.clickOn import io.element.android.tests.testutils.clickOn
import io.element.android.tests.testutils.ensureCalledOnceWithParam import io.element.android.tests.testutils.ensureCalledOnceWithParam
import io.element.android.wysiwyg.link.Link import io.element.android.wysiwyg.link.Link
import org.junit.Rule
import org.junit.Test import org.junit.Test
import org.junit.rules.TestRule
import org.junit.runner.RunWith import org.junit.runner.RunWith
@RunWith(AndroidJUnit4::class) @RunWith(AndroidJUnit4::class)
class LinkViewTest { class LinkViewTest {
@get:Rule
val rule = createAndroidComposeRule<ComponentActivity>()
@Test @Test
fun `clicking on cancel emits the expected event`() { fun `clicking on cancel emits the expected event`() = runAndroidComposeUiTest {
val eventsRecorder = EventsRecorder<LinkEvent>() val eventsRecorder = EventsRecorder<LinkEvent>()
rule.setLinkView( setLinkView(
aLinkState( aLinkState(
linkClick = ConfirmingLinkClick(aLink), linkClick = ConfirmingLinkClick(aLink),
eventSink = eventsRecorder, eventSink = eventsRecorder,
), ),
) )
rule.clickOn(CommonStrings.action_cancel) clickOn(CommonStrings.action_cancel)
eventsRecorder.assertSingle( eventsRecorder.assertSingle(
LinkEvent.Cancel LinkEvent.Cancel
) )
} }
@Test @Test
fun `clicking on continue emits the expected event`() { fun `clicking on continue emits the expected event`() = runAndroidComposeUiTest {
val eventsRecorder = EventsRecorder<LinkEvent>() val eventsRecorder = EventsRecorder<LinkEvent>()
rule.setLinkView( setLinkView(
aLinkState( aLinkState(
linkClick = ConfirmingLinkClick(aLink), linkClick = ConfirmingLinkClick(aLink),
eventSink = eventsRecorder, eventSink = eventsRecorder,
), ),
) )
rule.clickOn(CommonStrings.action_continue) clickOn(CommonStrings.action_continue)
eventsRecorder.assertSingle( eventsRecorder.assertSingle(
LinkEvent.Confirm LinkEvent.Confirm
) )
} }
@Test @Test
fun `success state invokes the callback and emits the expected event`() { fun `success state invokes the callback and emits the expected event`() = runAndroidComposeUiTest {
val eventsRecorder = EventsRecorder<LinkEvent>() val eventsRecorder = EventsRecorder<LinkEvent>()
ensureCalledOnceWithParam(aLink) { callback -> ensureCalledOnceWithParam(aLink) { callback ->
rule.setLinkView( setLinkView(
aLinkState( aLinkState(
linkClick = AsyncAction.Success(aLink), linkClick = AsyncAction.Success(aLink),
eventSink = eventsRecorder, eventSink = eventsRecorder,
@ -77,7 +75,7 @@ class LinkViewTest {
} }
} }
private fun <R : TestRule> AndroidComposeTestRule<R, ComponentActivity>.setLinkView( private fun AndroidComposeUiTest<ComponentActivity>.setLinkView(
state: LinkState, state: LinkState,
onLinkValid: (Link) -> Unit = EnsureNeverCalledWithParam(), onLinkValid: (Link) -> Unit = EnsureNeverCalledWithParam(),
) { ) {

View file

@ -6,13 +6,16 @@
* Please see LICENSE files in the repository root for full details. * Please see LICENSE files in the repository root for full details.
*/ */
@file:OptIn(ExperimentalTestApi::class)
package io.element.android.features.messages.impl.pinned.banner package io.element.android.features.messages.impl.pinned.banner
import androidx.activity.ComponentActivity import androidx.activity.ComponentActivity
import androidx.compose.ui.test.junit4.AndroidComposeTestRule import androidx.compose.ui.test.AndroidComposeUiTest
import androidx.compose.ui.test.junit4.createAndroidComposeRule import androidx.compose.ui.test.ExperimentalTestApi
import androidx.compose.ui.test.onRoot import androidx.compose.ui.test.onRoot
import androidx.compose.ui.test.performClick import androidx.compose.ui.test.performClick
import androidx.compose.ui.test.v2.runAndroidComposeUiTest
import androidx.test.ext.junit.runners.AndroidJUnit4 import androidx.test.ext.junit.runners.AndroidJUnit4
import io.element.android.libraries.matrix.api.core.EventId import io.element.android.libraries.matrix.api.core.EventId
import io.element.android.libraries.ui.strings.CommonStrings import io.element.android.libraries.ui.strings.CommonStrings
@ -22,49 +25,45 @@ import io.element.android.tests.testutils.EventsRecorder
import io.element.android.tests.testutils.clickOn import io.element.android.tests.testutils.clickOn
import io.element.android.tests.testutils.ensureCalledOnce import io.element.android.tests.testutils.ensureCalledOnce
import io.element.android.tests.testutils.ensureCalledOnceWithParam import io.element.android.tests.testutils.ensureCalledOnceWithParam
import org.junit.Rule
import org.junit.Test import org.junit.Test
import org.junit.rules.TestRule
import org.junit.runner.RunWith import org.junit.runner.RunWith
@RunWith(AndroidJUnit4::class) @RunWith(AndroidJUnit4::class)
class PinnedMessagesBannerViewTest { class PinnedMessagesBannerViewTest {
@get:Rule val rule = createAndroidComposeRule<ComponentActivity>()
@Test @Test
fun `clicking on the banner invoke expected callback`() { fun `clicking on the banner invoke expected callback`() = runAndroidComposeUiTest {
val eventsRecorder = EventsRecorder<PinnedMessagesBannerEvent>() val eventsRecorder = EventsRecorder<PinnedMessagesBannerEvent>()
val state = aLoadedPinnedMessagesBannerState( val state = aLoadedPinnedMessagesBannerState(
eventSink = eventsRecorder eventSink = eventsRecorder
) )
val pinnedEventId = state.currentPinnedMessage.eventId val pinnedEventId = state.currentPinnedMessage.eventId
ensureCalledOnceWithParam(pinnedEventId) { callback -> ensureCalledOnceWithParam(pinnedEventId) { callback ->
rule.setPinnedMessagesBannerView( setPinnedMessagesBannerView(
state = state, state = state,
onClick = callback onClick = callback
) )
rule.onRoot().performClick() onRoot().performClick()
eventsRecorder.assertSingle(PinnedMessagesBannerEvent.MoveToNextPinned) eventsRecorder.assertSingle(PinnedMessagesBannerEvent.MoveToNextPinned)
} }
} }
@Test @Test
fun `clicking on view all emit the expected event`() { fun `clicking on view all emit the expected event`() = runAndroidComposeUiTest {
val eventsRecorder = EventsRecorder<PinnedMessagesBannerEvent>(expectEvents = true) val eventsRecorder = EventsRecorder<PinnedMessagesBannerEvent>(expectEvents = true)
val state = aLoadedPinnedMessagesBannerState( val state = aLoadedPinnedMessagesBannerState(
eventSink = eventsRecorder eventSink = eventsRecorder
) )
ensureCalledOnce { callback -> ensureCalledOnce { callback ->
rule.setPinnedMessagesBannerView( setPinnedMessagesBannerView(
state = state, state = state,
onViewAllClick = callback onViewAllClick = callback
) )
rule.clickOn(CommonStrings.screen_room_pinned_banner_view_all_button_title) clickOn(CommonStrings.screen_room_pinned_banner_view_all_button_title)
} }
} }
} }
private fun <R : TestRule> AndroidComposeTestRule<R, ComponentActivity>.setPinnedMessagesBannerView( private fun AndroidComposeUiTest<ComponentActivity>.setPinnedMessagesBannerView(
state: PinnedMessagesBannerState, state: PinnedMessagesBannerState,
onClick: (EventId) -> Unit = EnsureNeverCalledWithParam(), onClick: (EventId) -> Unit = EnsureNeverCalledWithParam(),
onViewAllClick: () -> Unit = EnsureNeverCalled(), onViewAllClick: () -> Unit = EnsureNeverCalled(),

View file

@ -6,16 +6,19 @@
* Please see LICENSE files in the repository root for full details. * Please see LICENSE files in the repository root for full details.
*/ */
@file:OptIn(ExperimentalTestApi::class)
package io.element.android.features.messages.impl.pinned.list package io.element.android.features.messages.impl.pinned.list
import androidx.activity.ComponentActivity import androidx.activity.ComponentActivity
import androidx.compose.ui.test.junit4.AndroidComposeTestRule import androidx.compose.ui.test.AndroidComposeUiTest
import androidx.compose.ui.test.junit4.createAndroidComposeRule import androidx.compose.ui.test.ExperimentalTestApi
import androidx.compose.ui.test.longClick import androidx.compose.ui.test.longClick
import androidx.compose.ui.test.onAllNodesWithText import androidx.compose.ui.test.onAllNodesWithText
import androidx.compose.ui.test.onFirst import androidx.compose.ui.test.onFirst
import androidx.compose.ui.test.performClick import androidx.compose.ui.test.performClick
import androidx.compose.ui.test.performTouchInput import androidx.compose.ui.test.performTouchInput
import androidx.compose.ui.test.v2.runAndroidComposeUiTest
import androidx.test.ext.junit.runners.AndroidJUnit4 import androidx.test.ext.junit.runners.AndroidJUnit4
import io.element.android.features.messages.impl.actionlist.ActionListEvent import io.element.android.features.messages.impl.actionlist.ActionListEvent
import io.element.android.features.messages.impl.actionlist.anActionListState import io.element.android.features.messages.impl.actionlist.anActionListState
@ -31,33 +34,28 @@ import io.element.android.tests.testutils.ensureCalledOnceWithParam
import io.element.android.tests.testutils.pressBack import io.element.android.tests.testutils.pressBack
import io.element.android.tests.testutils.setSafeContent import io.element.android.tests.testutils.setSafeContent
import io.element.android.wysiwyg.link.Link import io.element.android.wysiwyg.link.Link
import org.junit.Rule
import org.junit.Test import org.junit.Test
import org.junit.rules.TestRule
import org.junit.runner.RunWith import org.junit.runner.RunWith
@RunWith(AndroidJUnit4::class) @RunWith(AndroidJUnit4::class)
class PinnedMessagesListViewTest { class PinnedMessagesListViewTest {
@get:Rule
val rule = createAndroidComposeRule<ComponentActivity>()
@Test @Test
fun `clicking on back calls the expected callback`() { fun `clicking on back calls the expected callback`() = runAndroidComposeUiTest {
val eventsRecorder = EventsRecorder<PinnedMessagesListEvent>(expectEvents = false) val eventsRecorder = EventsRecorder<PinnedMessagesListEvent>(expectEvents = false)
val state = aLoadedPinnedMessagesListState( val state = aLoadedPinnedMessagesListState(
eventSink = eventsRecorder eventSink = eventsRecorder
) )
ensureCalledOnce { callback -> ensureCalledOnce { callback ->
rule.setPinnedMessagesListView( setPinnedMessagesListView(
state = state, state = state,
onBackClick = callback onBackClick = callback
) )
rule.pressBack() pressBack()
} }
} }
@Test @Test
fun `click on an event calls the expected callback`() { fun `click on an event calls the expected callback`() = runAndroidComposeUiTest {
val eventsRecorder = EventsRecorder<PinnedMessagesListEvent>(expectEvents = false) val eventsRecorder = EventsRecorder<PinnedMessagesListEvent>(expectEvents = false)
val content = aTimelineItemFileContent() val content = aTimelineItemFileContent()
val state = aLoadedPinnedMessagesListState( val state = aLoadedPinnedMessagesListState(
@ -67,16 +65,16 @@ class PinnedMessagesListViewTest {
val event = state.timelineItems.first() as TimelineItem.Event val event = state.timelineItems.first() as TimelineItem.Event
ensureCalledOnceWithParam(event) { callback -> ensureCalledOnceWithParam(event) { callback ->
rule.setPinnedMessagesListView( setPinnedMessagesListView(
state = state, state = state,
onEventClick = callback onEventClick = callback
) )
rule.onAllNodesWithText(content.filename).onFirst().performClick() onAllNodesWithText(content.filename).onFirst().performClick()
} }
} }
@Test @Test
fun `long click on an event emits the expected event`() { fun `long click on an event emits the expected event`() = runAndroidComposeUiTest {
val eventsRecorder = EventsRecorder<ActionListEvent>(expectEvents = true) val eventsRecorder = EventsRecorder<ActionListEvent>(expectEvents = true)
val content = aTimelineItemFileContent() val content = aTimelineItemFileContent()
val state = aLoadedPinnedMessagesListState( val state = aLoadedPinnedMessagesListState(
@ -84,10 +82,10 @@ class PinnedMessagesListViewTest {
actionListState = anActionListState(eventSink = eventsRecorder) actionListState = anActionListState(eventSink = eventsRecorder)
) )
rule.setPinnedMessagesListView( setPinnedMessagesListView(
state = state, state = state,
) )
rule.onAllNodesWithText(content.filename).onFirst() onAllNodesWithText(content.filename).onFirst()
.performTouchInput { .performTouchInput {
longClick() longClick()
} }
@ -96,7 +94,7 @@ class PinnedMessagesListViewTest {
} }
} }
private fun <R : TestRule> AndroidComposeTestRule<R, ComponentActivity>.setPinnedMessagesListView( private fun AndroidComposeUiTest<ComponentActivity>.setPinnedMessagesListView(
state: PinnedMessagesListState, state: PinnedMessagesListState,
onBackClick: () -> Unit = EnsureNeverCalled(), onBackClick: () -> Unit = EnsureNeverCalled(),
onEventClick: (event: TimelineItem.Event) -> Unit = EnsureNeverCalledWithParam(), onEventClick: (event: TimelineItem.Event) -> Unit = EnsureNeverCalledWithParam(),

View file

@ -6,11 +6,14 @@
* Please see LICENSE files in the repository root for full details. * Please see LICENSE files in the repository root for full details.
*/ */
@file:OptIn(ExperimentalTestApi::class)
package io.element.android.features.messages.impl.timeline package io.element.android.features.messages.impl.timeline
import androidx.compose.runtime.CompositionLocalProvider import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.ui.platform.LocalInspectionMode import androidx.compose.ui.platform.LocalInspectionMode
import androidx.compose.ui.test.junit4.createComposeRule import androidx.compose.ui.test.ExperimentalTestApi
import androidx.compose.ui.test.v2.runComposeUiTest
import com.google.common.truth.Truth.assertThat import com.google.common.truth.Truth.assertThat
import io.element.android.features.messages.impl.utils.FakeMentionSpanFormatter import io.element.android.features.messages.impl.utils.FakeMentionSpanFormatter
import io.element.android.libraries.core.extensions.runCatchingExceptions import io.element.android.libraries.core.extensions.runCatchingExceptions
@ -18,15 +21,12 @@ import io.element.android.libraries.matrix.test.A_USER_ID
import io.element.android.libraries.matrix.test.permalink.FakePermalinkParser import io.element.android.libraries.matrix.test.permalink.FakePermalinkParser
import io.element.android.libraries.textcomposer.mentions.MentionSpanProvider import io.element.android.libraries.textcomposer.mentions.MentionSpanProvider
import io.element.android.libraries.textcomposer.mentions.MentionSpanTheme import io.element.android.libraries.textcomposer.mentions.MentionSpanTheme
import org.junit.Rule
import org.junit.Test import org.junit.Test
import org.junit.runner.RunWith import org.junit.runner.RunWith
import org.robolectric.RobolectricTestRunner import org.robolectric.RobolectricTestRunner
@RunWith(RobolectricTestRunner::class) @RunWith(RobolectricTestRunner::class)
class DefaultHtmlConverterProviderTest { class DefaultHtmlConverterProviderTest {
@get:Rule val composeTestRule = createComposeRule()
private val provider = DefaultHtmlConverterProvider( private val provider = DefaultHtmlConverterProvider(
mentionSpanProvider = MentionSpanProvider( mentionSpanProvider = MentionSpanProvider(
permalinkParser = FakePermalinkParser(), permalinkParser = FakePermalinkParser(),
@ -43,8 +43,8 @@ class DefaultHtmlConverterProviderTest {
} }
@Test @Test
fun `calling provide after calling Update first should return an HtmlConverter`() { fun `calling provide after calling Update first should return an HtmlConverter`() = runComposeUiTest {
composeTestRule.setContent { setContent {
CompositionLocalProvider(LocalInspectionMode provides true) { CompositionLocalProvider(LocalInspectionMode provides true) {
provider.Update() provider.Update()
} }

View file

@ -6,15 +6,18 @@
* Please see LICENSE files in the repository root for full details. * Please see LICENSE files in the repository root for full details.
*/ */
@file:OptIn(ExperimentalTestApi::class)
package io.element.android.features.messages.impl.timeline package io.element.android.features.messages.impl.timeline
import androidx.activity.ComponentActivity import androidx.activity.ComponentActivity
import androidx.compose.ui.test.junit4.AndroidComposeTestRule import androidx.compose.ui.test.AndroidComposeUiTest
import androidx.compose.ui.test.junit4.createAndroidComposeRule import androidx.compose.ui.test.ExperimentalTestApi
import androidx.compose.ui.test.onNodeWithContentDescription import androidx.compose.ui.test.onNodeWithContentDescription
import androidx.compose.ui.test.onNodeWithTag import androidx.compose.ui.test.onNodeWithTag
import androidx.compose.ui.test.performClick import androidx.compose.ui.test.performClick
import androidx.compose.ui.test.performScrollToIndex import androidx.compose.ui.test.performScrollToIndex
import androidx.compose.ui.test.v2.runAndroidComposeUiTest
import androidx.test.ext.junit.runners.AndroidJUnit4 import androidx.test.ext.junit.runners.AndroidJUnit4
import io.element.android.features.messages.impl.timeline.components.MessageShieldData import io.element.android.features.messages.impl.timeline.components.MessageShieldData
import io.element.android.features.messages.impl.timeline.components.aCriticalShield import io.element.android.features.messages.impl.timeline.components.aCriticalShield
@ -39,19 +42,15 @@ import io.element.android.wysiwyg.link.Link
import kotlinx.collections.immutable.persistentListOf import kotlinx.collections.immutable.persistentListOf
import kotlinx.collections.immutable.toImmutableList import kotlinx.collections.immutable.toImmutableList
import org.junit.Ignore import org.junit.Ignore
import org.junit.Rule
import org.junit.Test import org.junit.Test
import org.junit.rules.TestRule
import org.junit.runner.RunWith import org.junit.runner.RunWith
@RunWith(AndroidJUnit4::class) @RunWith(AndroidJUnit4::class)
class TimelineViewTest { class TimelineViewTest {
@get:Rule val rule = createAndroidComposeRule<ComponentActivity>()
@Test @Test
fun `reaching the end of the timeline with more events to load emits a LoadMore event`() { fun `reaching the end of the timeline with more events to load emits a LoadMore event`() = runAndroidComposeUiTest {
val eventsRecorder = EventsRecorder<TimelineEvent>() val eventsRecorder = EventsRecorder<TimelineEvent>()
rule.setTimelineView( setTimelineView(
state = aTimelineState( state = aTimelineState(
timelineItems = persistentListOf<TimelineItem>( timelineItems = persistentListOf<TimelineItem>(
TimelineItem.Virtual( TimelineItem.Virtual(
@ -66,9 +65,9 @@ class TimelineViewTest {
} }
@Test @Test
fun `reaching the end of the timeline does not send a LoadMore event`() { fun `reaching the end of the timeline does not send a LoadMore event`() = runAndroidComposeUiTest {
val eventsRecorder = EventsRecorder<TimelineEvent>() val eventsRecorder = EventsRecorder<TimelineEvent>()
rule.setTimelineView( setTimelineView(
state = aTimelineState( state = aTimelineState(
timelineItems = persistentListOf(aTimelineItemEvent(content = aTimelineItemImageContent())), timelineItems = persistentListOf(aTimelineItemEvent(content = aTimelineItemImageContent())),
eventSink = eventsRecorder, eventSink = eventsRecorder,
@ -78,9 +77,9 @@ class TimelineViewTest {
} }
@Test @Test
fun `scroll to bottom on live timeline does not emit the Event`() { fun `scroll to bottom on live timeline does not emit the Event`() = runAndroidComposeUiTest {
val eventsRecorder = EventsRecorder<TimelineEvent>() val eventsRecorder = EventsRecorder<TimelineEvent>()
rule.setTimelineView( setTimelineView(
state = aTimelineState( state = aTimelineState(
timelineItems = persistentListOf(aTimelineItemEvent(content = aTimelineItemImageContent())), timelineItems = persistentListOf(aTimelineItemEvent(content = aTimelineItemImageContent())),
isLive = true, isLive = true,
@ -92,14 +91,14 @@ class TimelineViewTest {
eventsRecorder.assertSingle(TimelineEvent.OnScrollFinished(firstIndex = 0)) eventsRecorder.assertSingle(TimelineEvent.OnScrollFinished(firstIndex = 0))
eventsRecorder.clear() eventsRecorder.clear()
val contentDescription = rule.activity.getString(CommonStrings.a11y_jump_to_bottom) val contentDescription = activity!!.getString(CommonStrings.a11y_jump_to_bottom)
rule.onNodeWithContentDescription(contentDescription).performClick() onNodeWithContentDescription(contentDescription).performClick()
} }
@Test @Test
fun `scroll to bottom on detached timeline emits the expected Event`() { fun `scroll to bottom on detached timeline emits the expected Event`() = runAndroidComposeUiTest {
val eventsRecorder = EventsRecorder<TimelineEvent>() val eventsRecorder = EventsRecorder<TimelineEvent>()
rule.setTimelineView( setTimelineView(
state = aTimelineState( state = aTimelineState(
timelineItems = persistentListOf(aTimelineItemEvent(content = aTimelineItemImageContent())), timelineItems = persistentListOf(aTimelineItemEvent(content = aTimelineItemImageContent())),
isLive = false, isLive = false,
@ -110,15 +109,15 @@ class TimelineViewTest {
eventsRecorder.assertSingle(TimelineEvent.OnScrollFinished(firstIndex = 0)) eventsRecorder.assertSingle(TimelineEvent.OnScrollFinished(firstIndex = 0))
eventsRecorder.clear() eventsRecorder.clear()
val contentDescription = rule.activity.getString(CommonStrings.a11y_jump_to_bottom) val contentDescription = activity!!.getString(CommonStrings.a11y_jump_to_bottom)
rule.onNodeWithContentDescription(contentDescription).performClick() onNodeWithContentDescription(contentDescription).performClick()
eventsRecorder.assertSingle(TimelineEvent.JumpToLive) eventsRecorder.assertSingle(TimelineEvent.JumpToLive)
} }
@Test @Test
fun `an empty timeline triggers a prefetch`() { fun `an empty timeline triggers a prefetch`() = runAndroidComposeUiTest {
val eventsRecorder = EventsRecorder<TimelineEvent>() val eventsRecorder = EventsRecorder<TimelineEvent>()
rule.setTimelineView( setTimelineView(
state = aTimelineState( state = aTimelineState(
timelineItems = persistentListOf(), timelineItems = persistentListOf(),
eventSink = eventsRecorder, eventSink = eventsRecorder,
@ -129,9 +128,9 @@ class TimelineViewTest {
} }
@Test @Test
fun `show shield dialog`() { fun `show shield dialog`() = runAndroidComposeUiTest {
val eventsRecorder = EventsRecorder<TimelineEvent>() val eventsRecorder = EventsRecorder<TimelineEvent>()
rule.setTimelineView( setTimelineView(
state = aTimelineState( state = aTimelineState(
timelineItems = persistentListOf<TimelineItem>( timelineItems = persistentListOf<TimelineItem>(
aTimelineItemEvent( aTimelineItemEvent(
@ -143,8 +142,8 @@ class TimelineViewTest {
eventSink = eventsRecorder, eventSink = eventsRecorder,
), ),
) )
val contentDescription = rule.activity.getString(CommonStrings.a11y_encryption_details) val contentDescription = activity!!.getString(CommonStrings.a11y_encryption_details)
rule.onNodeWithContentDescription(contentDescription).performClick() onNodeWithContentDescription(contentDescription).performClick()
eventsRecorder.assertList( eventsRecorder.assertList(
listOf( listOf(
TimelineEvent.OnScrollFinished(0), TimelineEvent.OnScrollFinished(0),
@ -154,9 +153,9 @@ class TimelineViewTest {
} }
@Test @Test
fun `hide shield dialog`() { fun `hide shield dialog`() = runAndroidComposeUiTest {
val eventsRecorder = EventsRecorder<TimelineEvent>() val eventsRecorder = EventsRecorder<TimelineEvent>()
rule.setTimelineView( setTimelineView(
state = aTimelineState( state = aTimelineState(
timelineItems = persistentListOf(aTimelineItemEvent(content = aTimelineItemImageContent())), timelineItems = persistentListOf(aTimelineItemEvent(content = aTimelineItemImageContent())),
isLive = false, isLive = false,
@ -167,16 +166,16 @@ class TimelineViewTest {
eventsRecorder.assertSingle(TimelineEvent.OnScrollFinished(firstIndex = 0)) eventsRecorder.assertSingle(TimelineEvent.OnScrollFinished(firstIndex = 0))
eventsRecorder.clear() eventsRecorder.clear()
rule.clickOn(CommonStrings.action_ok) clickOn(CommonStrings.action_ok)
eventsRecorder.assertSingle(TimelineEvent.HideShieldDialog) eventsRecorder.assertSingle(TimelineEvent.HideShieldDialog)
} }
@Ignore( @Ignore(
"performScrollToIndex in compose tests no longer sets LazyListState.isScrollInProgress to true, so the LoadMore event is not emitted." + "performScrollToIndex in compose tests no longer sets LazyListState.isScrollInProgress to true, so the LoadMore event is not emitted." +
"This needs to be reworked to use a different approach to check the LoadMore event was emitted." "This needs to be reworked to use a different approach to check the LoadMore event was emitted."
) )
@Test @Test
fun `scrolling near to the start of the loaded items triggers a pre-fetch`() { fun `scrolling near to the start of the loaded items triggers a pre-fetch`() = runAndroidComposeUiTest {
val eventsRecorder = EventsRecorder<TimelineEvent>() val eventsRecorder = EventsRecorder<TimelineEvent>()
val items = List<TimelineItem>(200) { val items = List<TimelineItem>(200) {
aTimelineItemEvent( aTimelineItemEvent(
@ -185,7 +184,7 @@ class TimelineViewTest {
) )
}.toImmutableList() }.toImmutableList()
rule.setTimelineView( setTimelineView(
state = aTimelineState( state = aTimelineState(
timelineItems = items, timelineItems = items,
eventSink = eventsRecorder, eventSink = eventsRecorder,
@ -194,9 +193,9 @@ class TimelineViewTest {
), ),
) )
rule.onNodeWithTag("timeline").performScrollToIndex(180) onNodeWithTag("timeline").performScrollToIndex(180)
rule.mainClock.advanceTimeBy(1000) mainClock.advanceTimeBy(1000)
eventsRecorder.assertList( eventsRecorder.assertList(
listOf( listOf(
@ -207,7 +206,7 @@ class TimelineViewTest {
} }
} }
private fun <R : TestRule> AndroidComposeTestRule<R, ComponentActivity>.setTimelineView( private fun AndroidComposeUiTest<ComponentActivity>.setTimelineView(
state: TimelineState, state: TimelineState,
timelineProtectionState: TimelineProtectionState = aTimelineProtectionState(), timelineProtectionState: TimelineProtectionState = aTimelineProtectionState(),
onUserDataClick: (MatrixUser) -> Unit = EnsureNeverCalledWithParam(), onUserDataClick: (MatrixUser) -> Unit = EnsureNeverCalledWithParam(),

View file

@ -6,12 +6,15 @@
* Please see LICENSE files in the repository root for full details. * Please see LICENSE files in the repository root for full details.
*/ */
@file:OptIn(ExperimentalTestApi::class)
package io.element.android.features.messages.impl.timeline.components.event package io.element.android.features.messages.impl.timeline.components.event
import androidx.activity.ComponentActivity import androidx.activity.ComponentActivity
import androidx.compose.ui.test.ExperimentalTestApi
import androidx.compose.ui.test.hasText import androidx.compose.ui.test.hasText
import androidx.compose.ui.test.junit4.createAndroidComposeRule
import androidx.compose.ui.test.performClick import androidx.compose.ui.test.performClick
import androidx.compose.ui.test.v2.runAndroidComposeUiTest
import androidx.test.ext.junit.runners.AndroidJUnit4 import androidx.test.ext.junit.runners.AndroidJUnit4
import io.element.android.features.messages.impl.timeline.TimelineEvent import io.element.android.features.messages.impl.timeline.TimelineEvent
import io.element.android.features.messages.impl.timeline.model.event.aTimelineItemPollContent import io.element.android.features.messages.impl.timeline.model.event.aTimelineItemPollContent
@ -20,14 +23,11 @@ import io.element.android.libraries.ui.strings.CommonStrings
import io.element.android.tests.testutils.EventsRecorder import io.element.android.tests.testutils.EventsRecorder
import io.element.android.tests.testutils.clickOn import io.element.android.tests.testutils.clickOn
import io.element.android.tests.testutils.pressTag import io.element.android.tests.testutils.pressTag
import org.junit.Rule
import org.junit.Test import org.junit.Test
import org.junit.runner.RunWith import org.junit.runner.RunWith
@RunWith(AndroidJUnit4::class) @RunWith(AndroidJUnit4::class)
class TimelineItemPollViewTest { class TimelineItemPollViewTest {
@get:Rule val rule = createAndroidComposeRule<ComponentActivity>()
@Test @Test
fun `answering a poll with first answer should emit a PollAnswerSelected event`() { fun `answering a poll with first answer should emit a PollAnswerSelected event`() {
testAnswer(answerIndex = 0) testAnswer(answerIndex = 0)
@ -38,17 +38,17 @@ class TimelineItemPollViewTest {
testAnswer(answerIndex = 1) testAnswer(answerIndex = 1)
} }
private fun testAnswer(answerIndex: Int) { private fun testAnswer(answerIndex: Int) = runAndroidComposeUiTest<ComponentActivity> {
val eventsRecorder = EventsRecorder<TimelineEvent.TimelineItemPollEvent>() val eventsRecorder = EventsRecorder<TimelineEvent.TimelineItemPollEvent>()
val content = aTimelineItemPollContent() val content = aTimelineItemPollContent()
rule.setContent { setContent {
TimelineItemPollView( TimelineItemPollView(
content = content, content = content,
eventSink = eventsRecorder eventSink = eventsRecorder
) )
} }
val answer = content.answerItems[answerIndex].answer val answer = content.answerItems[answerIndex].answer
rule.onNode( onNode(
matcher = hasText(answer.text), matcher = hasText(answer.text),
useUnmergedTree = true, useUnmergedTree = true,
).performClick() ).performClick()
@ -56,38 +56,38 @@ class TimelineItemPollViewTest {
} }
@Test @Test
fun `editing a poll should emit a PollEditClicked event`() { fun `editing a poll should emit a PollEditClicked event`() = runAndroidComposeUiTest {
val eventsRecorder = EventsRecorder<TimelineEvent.TimelineItemPollEvent>() val eventsRecorder = EventsRecorder<TimelineEvent.TimelineItemPollEvent>()
val content = aTimelineItemPollContent( val content = aTimelineItemPollContent(
isMine = true, isMine = true,
isEditable = true, isEditable = true,
) )
rule.setContent { setContent {
TimelineItemPollView( TimelineItemPollView(
content = content, content = content,
eventSink = eventsRecorder eventSink = eventsRecorder
) )
} }
rule.clickOn(CommonStrings.action_edit_poll) clickOn(CommonStrings.action_edit_poll)
eventsRecorder.assertSingle(TimelineEvent.EditPoll(content.eventId!!)) eventsRecorder.assertSingle(TimelineEvent.EditPoll(content.eventId!!))
} }
@Test @Test
fun `closing a poll should emit a PollEndClicked event`() { fun `closing a poll should emit a PollEndClicked event`() = runAndroidComposeUiTest {
val eventsRecorder = EventsRecorder<TimelineEvent.TimelineItemPollEvent>() val eventsRecorder = EventsRecorder<TimelineEvent.TimelineItemPollEvent>()
val content = aTimelineItemPollContent( val content = aTimelineItemPollContent(
isMine = true, isMine = true,
) )
rule.setContent { setContent {
TimelineItemPollView( TimelineItemPollView(
content = content, content = content,
eventSink = eventsRecorder eventSink = eventsRecorder
) )
} }
rule.clickOn(CommonStrings.action_end_poll) clickOn(CommonStrings.action_end_poll)
// A confirmation dialog should be shown // A confirmation dialog should be shown
eventsRecorder.assertEmpty() eventsRecorder.assertEmpty()
rule.pressTag(TestTags.dialogPositive.value) pressTag(TestTags.dialogPositive.value)
eventsRecorder.assertSingle(TimelineEvent.EndPoll(content.eventId!!)) eventsRecorder.assertSingle(TimelineEvent.EndPoll(content.eventId!!))
} }
} }

View file

@ -6,14 +6,17 @@
* Please see LICENSE files in the repository root for full details. * Please see LICENSE files in the repository root for full details.
*/ */
@file:OptIn(ExperimentalTestApi::class)
package io.element.android.features.messages.impl.timeline.components.event package io.element.android.features.messages.impl.timeline.components.event
import android.text.SpannableString import android.text.SpannableString
import android.text.SpannedString import android.text.SpannedString
import androidx.activity.ComponentActivity import androidx.activity.ComponentActivity
import androidx.compose.runtime.CompositionLocalProvider import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.ui.test.junit4.AndroidComposeTestRule import androidx.compose.ui.test.AndroidComposeUiTest
import androidx.compose.ui.test.junit4.createAndroidComposeRule import androidx.compose.ui.test.ExperimentalTestApi
import androidx.compose.ui.test.v2.runAndroidComposeUiTest
import androidx.core.text.buildSpannedString import androidx.core.text.buildSpannedString
import androidx.core.text.inSpans import androidx.core.text.inSpans
import androidx.test.ext.junit.runners.AndroidJUnit4 import androidx.test.ext.junit.runners.AndroidJUnit4
@ -38,45 +41,40 @@ import io.element.android.tests.testutils.lambda.assert
import io.element.android.tests.testutils.lambda.lambdaRecorder import io.element.android.tests.testutils.lambda.lambdaRecorder
import io.element.android.wysiwyg.view.spans.CustomMentionSpan import io.element.android.wysiwyg.view.spans.CustomMentionSpan
import kotlinx.coroutines.CompletableDeferred import kotlinx.coroutines.CompletableDeferred
import kotlinx.coroutines.test.runTest
import org.junit.Rule
import org.junit.Test import org.junit.Test
import org.junit.rules.TestRule
import org.junit.runner.RunWith import org.junit.runner.RunWith
@RunWith(AndroidJUnit4::class) @RunWith(AndroidJUnit4::class)
class TimelineTextViewTest { class TimelineTextViewTest {
@get:Rule val rule = createAndroidComposeRule<ComponentActivity>()
private val mentionSpanTheme = MentionSpanTheme(currentUserId = A_USER_ID) private val mentionSpanTheme = MentionSpanTheme(currentUserId = A_USER_ID)
private val formatLambda = lambdaRecorder<MentionType, CharSequence> { mentionType -> mentionType.toString() } private val formatLambda = lambdaRecorder<MentionType, CharSequence> { mentionType -> mentionType.toString() }
private val mentionSpanFormatter = FakeMentionSpanFormatter(formatLambda) private val mentionSpanFormatter = FakeMentionSpanFormatter(formatLambda)
@Test @Test
fun `getTextWithResolvedMentions - does nothing for a non spannable CharSequence`() = runTest { fun `getTextWithResolvedMentions - does nothing for a non spannable CharSequence`() = runAndroidComposeUiTest {
val charSequence = "Hello <a href=\"https://matrix.to/#/@alice:example.com\">@alice:example.com</a>" val charSequence = "Hello <a href=\"https://matrix.to/#/@alice:example.com\">@alice:example.com</a>"
val mentionSpanUpdater = aMentionSpanUpdater() val mentionSpanUpdater = aMentionSpanUpdater()
val result = rule.getText(mentionSpanUpdater, aTextContentWithFormattedBody(charSequence)) val result = getText(mentionSpanUpdater, aTextContentWithFormattedBody(charSequence))
assertThat(result.getMentionSpans()).isEmpty() assertThat(result.getMentionSpans()).isEmpty()
assert(formatLambda).isNeverCalled() assert(formatLambda).isNeverCalled()
} }
@Test @Test
fun `getTextWithResolvedMentions - does nothing if there are no mentions`() = runTest { fun `getTextWithResolvedMentions - does nothing if there are no mentions`() = runAndroidComposeUiTest {
val charSequence = SpannableString("Hello <a href=\"https://matrix.to/#/@alice:example.com\">@alice:example.com</a>") val charSequence = SpannableString("Hello <a href=\"https://matrix.to/#/@alice:example.com\">@alice:example.com</a>")
val mentionSpanUpdater = aMentionSpanUpdater() val mentionSpanUpdater = aMentionSpanUpdater()
val result = rule.getText(mentionSpanUpdater, aTextContentWithFormattedBody(charSequence)) val result = getText(mentionSpanUpdater, aTextContentWithFormattedBody(charSequence))
assertThat(result.getMentionSpans()).isEmpty() assertThat(result.getMentionSpans()).isEmpty()
assert(formatLambda).isNeverCalled() assert(formatLambda).isNeverCalled()
} }
@Test @Test
fun `getTextWithResolvedMentions - just returns the body if there is no formattedBody`() = runTest { fun `getTextWithResolvedMentions - just returns the body if there is no formattedBody`() = runAndroidComposeUiTest {
val charSequence = "Hello <a href=\"https://matrix.to/#/@alice:example.com\">@alice:example.com</a>" val charSequence = "Hello <a href=\"https://matrix.to/#/@alice:example.com\">@alice:example.com</a>"
val mentionSpanUpdater = aMentionSpanUpdater() val mentionSpanUpdater = aMentionSpanUpdater()
val result = rule.getText(mentionSpanUpdater, aTextContentWithFormattedBody(body = charSequence, formattedBody = null)) val result = getText(mentionSpanUpdater, aTextContentWithFormattedBody(body = charSequence, formattedBody = null))
assertThat(result.getMentionSpans()).isEmpty() assertThat(result.getMentionSpans()).isEmpty()
assertThat(result.toString()).isEqualTo(charSequence) assertThat(result.toString()).isEqualTo(charSequence)
@ -84,7 +82,7 @@ class TimelineTextViewTest {
} }
@Test @Test
fun `getTextWithResolvedMentions - with Room mention format correctly`() = runTest { fun `getTextWithResolvedMentions - with Room mention format correctly`() = runAndroidComposeUiTest {
val mentionType = MentionType.Room(roomIdOrAlias = A_ROOM_ID_2.toRoomIdOrAlias()) val mentionType = MentionType.Room(roomIdOrAlias = A_ROOM_ID_2.toRoomIdOrAlias())
val charSequence = buildSpannedString { val charSequence = buildSpannedString {
append("Hello ") append("Hello ")
@ -93,7 +91,7 @@ class TimelineTextViewTest {
} }
} }
val mentionSpanUpdater = aMentionSpanUpdater() val mentionSpanUpdater = aMentionSpanUpdater()
val result = rule.getText(mentionSpanUpdater, aTextContentWithFormattedBody(charSequence)) val result = getText(mentionSpanUpdater, aTextContentWithFormattedBody(charSequence))
val expectedDisplayText = mentionType.toString() val expectedDisplayText = mentionType.toString()
assertThat(result.getMentionSpans().firstOrNull()?.displayText.toString()).isEqualTo(expectedDisplayText) assertThat(result.getMentionSpans().firstOrNull()?.displayText.toString()).isEqualTo(expectedDisplayText)
@ -102,7 +100,7 @@ class TimelineTextViewTest {
} }
@Test @Test
fun `getTextWithResolvedMentions - replaces MentionSpan's text`() = runTest { fun `getTextWithResolvedMentions - replaces MentionSpan's text`() = runAndroidComposeUiTest {
val mentionType = MentionType.User(userId = A_USER_ID) val mentionType = MentionType.User(userId = A_USER_ID)
val charSequence = buildSpannedString { val charSequence = buildSpannedString {
append("Hello ") append("Hello ")
@ -111,7 +109,7 @@ class TimelineTextViewTest {
} }
} }
val mentionSpanUpdater = aMentionSpanUpdater() val mentionSpanUpdater = aMentionSpanUpdater()
val result = rule.getText(mentionSpanUpdater, aTextContentWithFormattedBody(charSequence)) val result = getText(mentionSpanUpdater, aTextContentWithFormattedBody(charSequence))
val expectedDisplayText = mentionType.toString() val expectedDisplayText = mentionType.toString()
assertThat(result.getMentionSpans().firstOrNull()?.displayText.toString()).isEqualTo(expectedDisplayText) assertThat(result.getMentionSpans().firstOrNull()?.displayText.toString()).isEqualTo(expectedDisplayText)
@ -119,7 +117,7 @@ class TimelineTextViewTest {
} }
@Test @Test
fun `getTextWithResolvedMentions - replaces MentionSpan's text inside CustomMentionSpan`() = runTest { fun `getTextWithResolvedMentions - replaces MentionSpan's text inside CustomMentionSpan`() = runAndroidComposeUiTest {
val mentionType = MentionType.User(userId = A_USER_ID) val mentionType = MentionType.User(userId = A_USER_ID)
val charSequence = buildSpannedString { val charSequence = buildSpannedString {
append("Hello ") append("Hello ")
@ -129,12 +127,12 @@ class TimelineTextViewTest {
} }
val mentionSpanUpdater = aMentionSpanUpdater() val mentionSpanUpdater = aMentionSpanUpdater()
val expectedDisplayText = mentionType.toString() val expectedDisplayText = mentionType.toString()
val result = rule.getText(mentionSpanUpdater, aTextContentWithFormattedBody(charSequence)) val result = getText(mentionSpanUpdater, aTextContentWithFormattedBody(charSequence))
assertThat(result.getMentionSpans().firstOrNull()?.displayText.toString()).isEqualTo(expectedDisplayText) assertThat(result.getMentionSpans().firstOrNull()?.displayText.toString()).isEqualTo(expectedDisplayText)
assert(formatLambda).isCalledOnce() assert(formatLambda).isCalledOnce()
} }
private suspend fun <R : TestRule> AndroidComposeTestRule<R, ComponentActivity>.getText( private suspend fun AndroidComposeUiTest<ComponentActivity>.getText(
mentionSpanUpdater: MentionSpanUpdater, mentionSpanUpdater: MentionSpanUpdater,
content: TimelineItemTextBasedContent, content: TimelineItemTextBasedContent,
): CharSequence { ): CharSequence {

View file

@ -6,56 +6,55 @@
* Please see LICENSE files in the repository root for full details. * Please see LICENSE files in the repository root for full details.
*/ */
@file:OptIn(ExperimentalTestApi::class)
package io.element.android.features.messages.impl.timeline.protection package io.element.android.features.messages.impl.timeline.protection
import androidx.activity.ComponentActivity import androidx.activity.ComponentActivity
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.ui.test.junit4.AndroidComposeTestRule import androidx.compose.ui.test.AndroidComposeUiTest
import androidx.compose.ui.test.junit4.createAndroidComposeRule import androidx.compose.ui.test.ExperimentalTestApi
import androidx.compose.ui.test.onNodeWithText import androidx.compose.ui.test.onNodeWithText
import androidx.compose.ui.test.v2.runAndroidComposeUiTest
import androidx.test.ext.junit.runners.AndroidJUnit4 import androidx.test.ext.junit.runners.AndroidJUnit4
import io.element.android.libraries.designsystem.theme.components.Text import io.element.android.libraries.designsystem.theme.components.Text
import io.element.android.libraries.ui.strings.CommonStrings import io.element.android.libraries.ui.strings.CommonStrings
import io.element.android.tests.testutils.clickOn import io.element.android.tests.testutils.clickOn
import io.element.android.tests.testutils.ensureCalledOnce import io.element.android.tests.testutils.ensureCalledOnce
import io.element.android.tests.testutils.lambda.lambdaError import io.element.android.tests.testutils.lambda.lambdaError
import org.junit.Rule
import org.junit.Test import org.junit.Test
import org.junit.rules.TestRule
import org.junit.runner.RunWith import org.junit.runner.RunWith
@RunWith(AndroidJUnit4::class) @RunWith(AndroidJUnit4::class)
class ProtectedViewTest { class ProtectedViewTest {
@get:Rule val rule = createAndroidComposeRule<ComponentActivity>()
@Test @Test
fun `when hideContent is false, the content is rendered`() { fun `when hideContent is false, the content is rendered`() = runAndroidComposeUiTest {
rule.setProtectedView( setProtectedView(
hideContent = false, hideContent = false,
content = { content = {
Text("Hello") Text("Hello")
} }
) )
rule.onNodeWithText("Hello").assertExists() onNodeWithText("Hello").assertExists()
} }
@Test @Test
fun `when hideContent is true, the content is not rendered, and user can reveal it`() { fun `when hideContent is true, the content is not rendered, and user can reveal it`() = runAndroidComposeUiTest {
ensureCalledOnce { ensureCalledOnce {
rule.setProtectedView( setProtectedView(
hideContent = true, hideContent = true,
onShowClick = it, onShowClick = it,
content = { content = {
Text("Hello") Text("Hello")
} }
) )
rule.onNodeWithText("Hello").assertDoesNotExist() onNodeWithText("Hello").assertDoesNotExist()
rule.clickOn(CommonStrings.action_show) clickOn(CommonStrings.action_show)
} }
} }
} }
private fun <R : TestRule> AndroidComposeTestRule<R, ComponentActivity>.setProtectedView( private fun AndroidComposeUiTest<ComponentActivity>.setProtectedView(
hideContent: Boolean = false, hideContent: Boolean = false,
onShowClick: () -> Unit = { lambdaError() }, onShowClick: () -> Unit = { lambdaError() },
content: @Composable () -> Unit = {}, content: @Composable () -> Unit = {},

View file

@ -16,6 +16,7 @@ android {
dependencies { dependencies {
api(projects.features.messages.impl) api(projects.features.messages.impl)
implementation(projects.libraries.architecture)
implementation(projects.libraries.matrix.test) implementation(projects.libraries.matrix.test)
implementation(projects.libraries.audio.test) implementation(projects.libraries.audio.test)
implementation(projects.libraries.mediaplayer.test) implementation(projects.libraries.mediaplayer.test)

View file

@ -6,13 +6,16 @@
* Please see LICENSE files in the repository root for full details. * Please see LICENSE files in the repository root for full details.
*/ */
@file:OptIn(ExperimentalTestApi::class)
package io.element.android.features.poll.impl.history package io.element.android.features.poll.impl.history
import androidx.activity.ComponentActivity import androidx.activity.ComponentActivity
import androidx.compose.ui.test.junit4.AndroidComposeTestRule import androidx.compose.ui.test.AndroidComposeUiTest
import androidx.compose.ui.test.junit4.createAndroidComposeRule import androidx.compose.ui.test.ExperimentalTestApi
import androidx.compose.ui.test.onNodeWithText import androidx.compose.ui.test.onNodeWithText
import androidx.compose.ui.test.performClick import androidx.compose.ui.test.performClick
import androidx.compose.ui.test.v2.runAndroidComposeUiTest
import androidx.test.ext.junit.runners.AndroidJUnit4 import androidx.test.ext.junit.runners.AndroidJUnit4
import io.element.android.features.poll.api.pollcontent.aPollContentState import io.element.android.features.poll.api.pollcontent.aPollContentState
import io.element.android.features.poll.impl.R import io.element.android.features.poll.impl.R
@ -26,34 +29,29 @@ import io.element.android.tests.testutils.clickOn
import io.element.android.tests.testutils.ensureCalledOnce import io.element.android.tests.testutils.ensureCalledOnce
import io.element.android.tests.testutils.ensureCalledOnceWithParam import io.element.android.tests.testutils.ensureCalledOnceWithParam
import io.element.android.tests.testutils.pressBack import io.element.android.tests.testutils.pressBack
import org.junit.Rule
import org.junit.Test import org.junit.Test
import org.junit.rules.TestRule
import org.junit.runner.RunWith import org.junit.runner.RunWith
import org.robolectric.annotation.Config import org.robolectric.annotation.Config
@RunWith(AndroidJUnit4::class) @RunWith(AndroidJUnit4::class)
class PollHistoryViewTest { class PollHistoryViewTest {
@get:Rule
val rule = createAndroidComposeRule<ComponentActivity>()
@Test @Test
fun `clicking on back invokes the expected callback`() { fun `clicking on back invokes the expected callback`() = runAndroidComposeUiTest {
val eventsRecorder = EventsRecorder<PollHistoryEvents>(expectEvents = false) val eventsRecorder = EventsRecorder<PollHistoryEvents>(expectEvents = false)
ensureCalledOnce { ensureCalledOnce {
rule.setPollHistoryViewView( setPollHistoryViewView(
aPollHistoryState( aPollHistoryState(
eventSink = eventsRecorder eventSink = eventsRecorder
), ),
goBack = it goBack = it
) )
rule.pressBack() pressBack()
} }
} }
@Config(qualifiers = "h1024dp") @Config(qualifiers = "h1024dp")
@Test @Test
fun `clicking on edit poll invokes the expected callback`() { fun `clicking on edit poll invokes the expected callback`() = runAndroidComposeUiTest {
val eventsRecorder = EventsRecorder<PollHistoryEvents>(expectEvents = false) val eventsRecorder = EventsRecorder<PollHistoryEvents>(expectEvents = false)
val eventId = EventId("\$anEventId") val eventId = EventId("\$anEventId")
val state = aPollHistoryState( val state = aPollHistoryState(
@ -69,17 +67,17 @@ class PollHistoryViewTest {
eventSink = eventsRecorder eventSink = eventsRecorder
) )
ensureCalledOnceWithParam(eventId) { ensureCalledOnceWithParam(eventId) {
rule.setPollHistoryViewView( setPollHistoryViewView(
state = state, state = state,
onEditPoll = it onEditPoll = it
) )
rule.clickOn(CommonStrings.action_edit_poll) clickOn(CommonStrings.action_edit_poll)
} }
} }
@Config(qualifiers = "h1024dp") @Config(qualifiers = "h1024dp")
@Test @Test
fun `clicking on poll end emits the expected Event`() { fun `clicking on poll end emits the expected Event`() = runAndroidComposeUiTest {
val eventsRecorder = EventsRecorder<PollHistoryEvents>() val eventsRecorder = EventsRecorder<PollHistoryEvents>()
val eventId = EventId("\$anEventId") val eventId = EventId("\$anEventId")
val state = aPollHistoryState( val state = aPollHistoryState(
@ -95,16 +93,16 @@ class PollHistoryViewTest {
), ),
eventSink = eventsRecorder eventSink = eventsRecorder
) )
rule.setPollHistoryViewView( setPollHistoryViewView(
state = state, state = state,
) )
rule.clickOn(CommonStrings.action_end_poll) clickOn(CommonStrings.action_end_poll)
// Cancel the dialog // Cancel the dialog
rule.clickOn(CommonStrings.action_cancel) clickOn(CommonStrings.action_cancel)
// Do it again, and confirm the dialog // Do it again, and confirm the dialog
rule.clickOn(CommonStrings.action_end_poll) clickOn(CommonStrings.action_end_poll)
eventsRecorder.assertEmpty() eventsRecorder.assertEmpty()
rule.clickOn(CommonStrings.action_ok) clickOn(CommonStrings.action_ok)
eventsRecorder.assertSingle( eventsRecorder.assertSingle(
PollHistoryEvents.EndPoll(eventId) PollHistoryEvents.EndPoll(eventId)
) )
@ -112,7 +110,7 @@ class PollHistoryViewTest {
@Config(qualifiers = "h1024dp") @Config(qualifiers = "h1024dp")
@Test @Test
fun `clicking on poll answer emits the expected Event`() { fun `clicking on poll answer emits the expected Event`() = runAndroidComposeUiTest {
val eventsRecorder = EventsRecorder<PollHistoryEvents>() val eventsRecorder = EventsRecorder<PollHistoryEvents>()
val eventId = EventId("\$anEventId") val eventId = EventId("\$anEventId")
val state = aPollHistoryState( val state = aPollHistoryState(
@ -129,10 +127,10 @@ class PollHistoryViewTest {
eventSink = eventsRecorder eventSink = eventsRecorder
) )
val answer = state.pollHistoryItems.ongoing.first().state.answerItems.first().answer val answer = state.pollHistoryItems.ongoing.first().state.answerItems.first().answer
rule.setPollHistoryViewView( setPollHistoryViewView(
state = state, state = state,
) )
rule.onNodeWithText( onNodeWithText(
text = answer.text, text = answer.text,
useUnmergedTree = true, useUnmergedTree = true,
).performClick() ).performClick()
@ -142,14 +140,14 @@ class PollHistoryViewTest {
} }
@Test @Test
fun `clicking on past tab emits the expected Event`() { fun `clicking on past tab emits the expected Event`() = runAndroidComposeUiTest {
val eventsRecorder = EventsRecorder<PollHistoryEvents>() val eventsRecorder = EventsRecorder<PollHistoryEvents>()
rule.setPollHistoryViewView( setPollHistoryViewView(
aPollHistoryState( aPollHistoryState(
eventSink = eventsRecorder eventSink = eventsRecorder
), ),
) )
rule.clickOn(R.string.screen_polls_history_filter_past) clickOn(R.string.screen_polls_history_filter_past)
eventsRecorder.assertSingle( eventsRecorder.assertSingle(
PollHistoryEvents.SelectFilter(filter = PollHistoryFilter.PAST) PollHistoryEvents.SelectFilter(filter = PollHistoryFilter.PAST)
) )
@ -157,22 +155,22 @@ class PollHistoryViewTest {
@Config(qualifiers = "h1024dp") @Config(qualifiers = "h1024dp")
@Test @Test
fun `clicking on load more emits the expected Event`() { fun `clicking on load more emits the expected Event`() = runAndroidComposeUiTest {
val eventsRecorder = EventsRecorder<PollHistoryEvents>() val eventsRecorder = EventsRecorder<PollHistoryEvents>()
rule.setPollHistoryViewView( setPollHistoryViewView(
aPollHistoryState( aPollHistoryState(
hasMoreToLoad = true, hasMoreToLoad = true,
eventSink = eventsRecorder, eventSink = eventsRecorder,
), ),
) )
rule.clickOn(CommonStrings.action_load_more) clickOn(CommonStrings.action_load_more)
eventsRecorder.assertSingle( eventsRecorder.assertSingle(
PollHistoryEvents.LoadMore PollHistoryEvents.LoadMore
) )
} }
} }
private fun <R : TestRule> AndroidComposeTestRule<R, ComponentActivity>.setPollHistoryViewView( private fun AndroidComposeUiTest<ComponentActivity>.setPollHistoryViewView(
state: PollHistoryState, state: PollHistoryState,
onEditPoll: (EventId) -> Unit = EnsureNeverCalledWithParam(), onEditPoll: (EventId) -> Unit = EnsureNeverCalledWithParam(),
goBack: () -> Unit = EnsureNeverCalled(), goBack: () -> Unit = EnsureNeverCalled(),

View file

@ -15,6 +15,7 @@ android {
} }
dependencies { dependencies {
implementation(projects.libraries.architecture)
implementation(projects.libraries.matrix.api) implementation(projects.libraries.matrix.api)
api(projects.features.poll.api) api(projects.features.poll.api)
implementation(libs.kotlinx.collections.immutable) implementation(libs.kotlinx.collections.immutable)

View file

@ -68,6 +68,7 @@ dependencies {
implementation(projects.libraries.permissions.api) implementation(projects.libraries.permissions.api)
implementation(projects.libraries.push.api) implementation(projects.libraries.push.api)
implementation(projects.libraries.pushproviders.api) implementation(projects.libraries.pushproviders.api)
implementation(projects.libraries.sessionStorage.api)
implementation(projects.libraries.uiUtils) implementation(projects.libraries.uiUtils)
implementation(projects.libraries.fullscreenintent.api) implementation(projects.libraries.fullscreenintent.api)
implementation(projects.features.rageshake.api) implementation(projects.features.rageshake.api)

View file

@ -6,11 +6,14 @@
* Please see LICENSE files in the repository root for full details. * Please see LICENSE files in the repository root for full details.
*/ */
@file:OptIn(ExperimentalTestApi::class)
package io.element.android.features.preferences.impl.about package io.element.android.features.preferences.impl.about
import androidx.activity.ComponentActivity import androidx.activity.ComponentActivity
import androidx.compose.ui.test.junit4.AndroidComposeTestRule import androidx.compose.ui.test.AndroidComposeUiTest
import androidx.compose.ui.test.junit4.createAndroidComposeRule import androidx.compose.ui.test.ExperimentalTestApi
import androidx.compose.ui.test.v2.runAndroidComposeUiTest
import androidx.test.ext.junit.runners.AndroidJUnit4 import androidx.test.ext.junit.runners.AndroidJUnit4
import io.element.android.libraries.ui.strings.CommonStrings import io.element.android.libraries.ui.strings.CommonStrings
import io.element.android.tests.testutils.EnsureNeverCalled import io.element.android.tests.testutils.EnsureNeverCalled
@ -19,51 +22,47 @@ import io.element.android.tests.testutils.clickOn
import io.element.android.tests.testutils.ensureCalledOnce import io.element.android.tests.testutils.ensureCalledOnce
import io.element.android.tests.testutils.ensureCalledOnceWithParam import io.element.android.tests.testutils.ensureCalledOnceWithParam
import io.element.android.tests.testutils.pressBack import io.element.android.tests.testutils.pressBack
import org.junit.Rule
import org.junit.Test import org.junit.Test
import org.junit.rules.TestRule
import org.junit.runner.RunWith import org.junit.runner.RunWith
@RunWith(AndroidJUnit4::class) @RunWith(AndroidJUnit4::class)
class AboutViewTest { class AboutViewTest {
@get:Rule val rule = createAndroidComposeRule<ComponentActivity>()
@Test @Test
fun `clicking on back invokes back callback`() { fun `clicking on back invokes back callback`() = runAndroidComposeUiTest {
ensureCalledOnce { callback -> ensureCalledOnce { callback ->
rule.setAboutView( setAboutView(
anAboutState(), anAboutState(),
onBackClick = callback, onBackClick = callback,
) )
rule.pressBack() pressBack()
} }
} }
@Test @Test
fun `clicking on an item invokes the expected callback`() { fun `clicking on an item invokes the expected callback`() = runAndroidComposeUiTest {
val state = anAboutState() val state = anAboutState()
ensureCalledOnceWithParam(state.elementLegals.first()) { callback -> ensureCalledOnceWithParam(state.elementLegals.first()) { callback ->
rule.setAboutView( setAboutView(
state, state,
onElementLegalClick = callback, onElementLegalClick = callback,
) )
rule.clickOn(state.elementLegals.first().titleRes) clickOn(state.elementLegals.first().titleRes)
} }
} }
@Test @Test
fun `clicking on the open source licenses invokes the expected callback`() { fun `clicking on the open source licenses invokes the expected callback`() = runAndroidComposeUiTest {
ensureCalledOnce { callback -> ensureCalledOnce { callback ->
rule.setAboutView( setAboutView(
anAboutState(), anAboutState(),
onOpenSourceLicensesClick = callback, onOpenSourceLicensesClick = callback,
) )
rule.clickOn(CommonStrings.common_open_source_licenses) clickOn(CommonStrings.common_open_source_licenses)
} }
} }
} }
private fun <R : TestRule> AndroidComposeTestRule<R, ComponentActivity>.setAboutView( private fun AndroidComposeUiTest<ComponentActivity>.setAboutView(
state: AboutState, state: AboutState,
onElementLegalClick: (ElementLegal) -> Unit = EnsureNeverCalledWithParam(), onElementLegalClick: (ElementLegal) -> Unit = EnsureNeverCalledWithParam(),
onOpenSourceLicensesClick: () -> Unit = EnsureNeverCalled(), onOpenSourceLicensesClick: () -> Unit = EnsureNeverCalled(),

View file

@ -6,13 +6,16 @@
* Please see LICENSE files in the repository root for full details. * Please see LICENSE files in the repository root for full details.
*/ */
@file:OptIn(ExperimentalTestApi::class)
package io.element.android.features.preferences.impl.advanced package io.element.android.features.preferences.impl.advanced
import androidx.activity.ComponentActivity import androidx.activity.ComponentActivity
import androidx.compose.runtime.CompositionLocalProvider import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.ui.test.junit4.AndroidComposeTestRule import androidx.compose.ui.test.AndroidComposeUiTest
import androidx.compose.ui.test.junit4.createAndroidComposeRule import androidx.compose.ui.test.ExperimentalTestApi
import androidx.compose.ui.test.onNodeWithText import androidx.compose.ui.test.onNodeWithText
import androidx.compose.ui.test.v2.runAndroidComposeUiTest
import androidx.test.ext.junit.runners.AndroidJUnit4 import androidx.test.ext.junit.runners.AndroidJUnit4
import com.google.common.truth.Truth.assertThat import com.google.common.truth.Truth.assertThat
import im.vector.app.features.analytics.plan.Interaction import im.vector.app.features.analytics.plan.Interaction
@ -30,104 +33,99 @@ import io.element.android.tests.testutils.clickOn
import io.element.android.tests.testutils.ensureCalledOnce import io.element.android.tests.testutils.ensureCalledOnce
import io.element.android.tests.testutils.pressBack import io.element.android.tests.testutils.pressBack
import kotlinx.collections.immutable.toImmutableList import kotlinx.collections.immutable.toImmutableList
import org.junit.Rule
import org.junit.Test import org.junit.Test
import org.junit.rules.TestRule
import org.junit.runner.RunWith import org.junit.runner.RunWith
import org.robolectric.annotation.Config import org.robolectric.annotation.Config
@RunWith(AndroidJUnit4::class) @RunWith(AndroidJUnit4::class)
class AdvancedSettingsViewTest { class AdvancedSettingsViewTest {
@get:Rule
val rule = createAndroidComposeRule<ComponentActivity>()
@Test @Test
fun `clicking on back invokes the expected callback`() { fun `clicking on back invokes the expected callback`() = runAndroidComposeUiTest {
val eventsRecorder = EventsRecorder<AdvancedSettingsEvents>(expectEvents = false) val eventsRecorder = EventsRecorder<AdvancedSettingsEvents>(expectEvents = false)
ensureCalledOnce { ensureCalledOnce {
rule.setAdvancedSettingsView( setAdvancedSettingsView(
state = aAdvancedSettingsState( state = aAdvancedSettingsState(
eventSink = eventsRecorder eventSink = eventsRecorder
), ),
onBackClick = it onBackClick = it
) )
rule.pressBack() pressBack()
} }
} }
@Test @Test
fun `clicking on other theme emits the expected event`() { fun `clicking on other theme emits the expected event`() = runAndroidComposeUiTest {
val eventsRecorder = EventsRecorder<AdvancedSettingsEvents>() val eventsRecorder = EventsRecorder<AdvancedSettingsEvents>()
rule.setAdvancedSettingsView( setAdvancedSettingsView(
state = aAdvancedSettingsState( state = aAdvancedSettingsState(
eventSink = eventsRecorder, eventSink = eventsRecorder,
), ),
) )
rule.clickOn(CommonStrings.common_appearance) clickOn(CommonStrings.common_appearance)
rule.clickOn(CommonStrings.common_dark) clickOn(CommonStrings.common_dark)
eventsRecorder.assertSingle(AdvancedSettingsEvents.SetTheme(ThemeOption.Dark)) eventsRecorder.assertSingle(AdvancedSettingsEvents.SetTheme(ThemeOption.Dark))
} }
@Test @Test
fun `black theme is shown when available`() { fun `black theme is shown when available`() = runAndroidComposeUiTest {
rule.setAdvancedSettingsView( setAdvancedSettingsView(
state = aAdvancedSettingsState( state = aAdvancedSettingsState(
availableThemeOptions = ThemeOption.entries.toImmutableList(), availableThemeOptions = ThemeOption.entries.toImmutableList(),
), ),
) )
rule.clickOn(CommonStrings.common_appearance) clickOn(CommonStrings.common_appearance)
rule.run { run {
val text = activity.getString(CommonStrings.common_black) val text = activity!!.getString(CommonStrings.common_black)
onNodeWithText(text).assertExists() onNodeWithText(text).assertExists()
} }
} }
@Test @Test
fun `black theme is hidden when unavailable`() { fun `black theme is hidden when unavailable`() = runAndroidComposeUiTest {
rule.setAdvancedSettingsView( setAdvancedSettingsView(
state = aAdvancedSettingsState( state = aAdvancedSettingsState(
availableThemeOptions = ThemeOption.entries.filterNot { it == ThemeOption.Black }.toImmutableList(), availableThemeOptions = ThemeOption.entries.filterNot { it == ThemeOption.Black }.toImmutableList(),
), ),
) )
rule.clickOn(CommonStrings.common_appearance) clickOn(CommonStrings.common_appearance)
rule.assertNoNodeWithText(CommonStrings.common_black) assertNoNodeWithText(CommonStrings.common_black)
} }
@Test @Test
fun `clicking on View source emits the expected event`() { fun `clicking on View source emits the expected event`() = runAndroidComposeUiTest {
val eventsRecorder = EventsRecorder<AdvancedSettingsEvents>() val eventsRecorder = EventsRecorder<AdvancedSettingsEvents>()
rule.setAdvancedSettingsView( setAdvancedSettingsView(
state = aAdvancedSettingsState( state = aAdvancedSettingsState(
eventSink = eventsRecorder, eventSink = eventsRecorder,
), ),
) )
rule.clickOn(CommonStrings.action_view_source) clickOn(CommonStrings.action_view_source)
eventsRecorder.assertSingle(AdvancedSettingsEvents.SetDeveloperModeEnabled(true)) eventsRecorder.assertSingle(AdvancedSettingsEvents.SetDeveloperModeEnabled(true))
} }
@Test @Test
fun `clicking on Share presence emits the expected event`() { fun `clicking on Share presence emits the expected event`() = runAndroidComposeUiTest {
val eventsRecorder = EventsRecorder<AdvancedSettingsEvents>() val eventsRecorder = EventsRecorder<AdvancedSettingsEvents>()
rule.setAdvancedSettingsView( setAdvancedSettingsView(
state = aAdvancedSettingsState( state = aAdvancedSettingsState(
eventSink = eventsRecorder, eventSink = eventsRecorder,
), ),
) )
rule.clickOn(R.string.screen_advanced_settings_share_presence) clickOn(R.string.screen_advanced_settings_share_presence)
eventsRecorder.assertSingle(AdvancedSettingsEvents.SetSharePresenceEnabled(true)) eventsRecorder.assertSingle(AdvancedSettingsEvents.SetSharePresenceEnabled(true))
} }
@Test @Test
fun `clicking on media to enable compression emits the expected event`() { fun `clicking on media to enable compression emits the expected event`() = runAndroidComposeUiTest {
val eventsRecorder = EventsRecorder<AdvancedSettingsEvents>() val eventsRecorder = EventsRecorder<AdvancedSettingsEvents>()
val analyticsService = FakeAnalyticsService() val analyticsService = FakeAnalyticsService()
rule.setAdvancedSettingsView( setAdvancedSettingsView(
state = aAdvancedSettingsState( state = aAdvancedSettingsState(
eventSink = eventsRecorder, eventSink = eventsRecorder,
), ),
analyticsService = analyticsService analyticsService = analyticsService
) )
rule.clickOn(R.string.screen_advanced_settings_media_compression_description) clickOn(R.string.screen_advanced_settings_media_compression_description)
eventsRecorder.assertSingle(AdvancedSettingsEvents.SetCompressMedia(true)) eventsRecorder.assertSingle(AdvancedSettingsEvents.SetCompressMedia(true))
assertThat(analyticsService.capturedEvents).isEqualTo( assertThat(analyticsService.capturedEvents).isEqualTo(
listOf( listOf(
@ -139,17 +137,17 @@ class AdvancedSettingsViewTest {
} }
@Test @Test
fun `clicking on media to disable compression emits the expected event`() { fun `clicking on media to disable compression emits the expected event`() = runAndroidComposeUiTest {
val eventsRecorder = EventsRecorder<AdvancedSettingsEvents>() val eventsRecorder = EventsRecorder<AdvancedSettingsEvents>()
val analyticsService = FakeAnalyticsService() val analyticsService = FakeAnalyticsService()
rule.setAdvancedSettingsView( setAdvancedSettingsView(
state = aAdvancedSettingsState( state = aAdvancedSettingsState(
mediaOptimizationState = MediaOptimizationState.AllMedia(isEnabled = true), mediaOptimizationState = MediaOptimizationState.AllMedia(isEnabled = true),
eventSink = eventsRecorder, eventSink = eventsRecorder,
), ),
analyticsService = analyticsService analyticsService = analyticsService
) )
rule.clickOn(R.string.screen_advanced_settings_media_compression_description) clickOn(R.string.screen_advanced_settings_media_compression_description)
eventsRecorder.assertSingle(AdvancedSettingsEvents.SetCompressMedia(false)) eventsRecorder.assertSingle(AdvancedSettingsEvents.SetCompressMedia(false))
assertThat(analyticsService.capturedEvents).isEqualTo( assertThat(analyticsService.capturedEvents).isEqualTo(
listOf( listOf(
@ -162,65 +160,65 @@ class AdvancedSettingsViewTest {
@Test @Test
@Config(qualifiers = "h1080dp") @Config(qualifiers = "h1080dp")
fun `clicking on hide invite avatars emits the expected event`() { fun `clicking on hide invite avatars emits the expected event`() = runAndroidComposeUiTest {
val eventsRecorder = EventsRecorder<AdvancedSettingsEvents>() val eventsRecorder = EventsRecorder<AdvancedSettingsEvents>()
rule.setAdvancedSettingsView( setAdvancedSettingsView(
state = aAdvancedSettingsState( state = aAdvancedSettingsState(
eventSink = eventsRecorder, eventSink = eventsRecorder,
hideInviteAvatars = false hideInviteAvatars = false
), ),
) )
rule.clickOn(R.string.screen_advanced_settings_hide_invite_avatars_toggle_title) clickOn(R.string.screen_advanced_settings_hide_invite_avatars_toggle_title)
eventsRecorder.assertSingle(AdvancedSettingsEvents.SetHideInviteAvatars(true)) eventsRecorder.assertSingle(AdvancedSettingsEvents.SetHideInviteAvatars(true))
} }
@Test @Test
@Config(qualifiers = "h1080dp") @Config(qualifiers = "h1080dp")
fun `clicking on timeline media preview always hide emits the expected event`() { fun `clicking on timeline media preview always hide emits the expected event`() = runAndroidComposeUiTest {
val eventsRecorder = EventsRecorder<AdvancedSettingsEvents>() val eventsRecorder = EventsRecorder<AdvancedSettingsEvents>()
rule.setAdvancedSettingsView( setAdvancedSettingsView(
state = aAdvancedSettingsState( state = aAdvancedSettingsState(
eventSink = eventsRecorder, eventSink = eventsRecorder,
timelineMediaPreviewValue = MediaPreviewValue.On timelineMediaPreviewValue = MediaPreviewValue.On
), ),
) )
rule.clickOn(R.string.screen_advanced_settings_show_media_timeline_always_hide) clickOn(R.string.screen_advanced_settings_show_media_timeline_always_hide)
eventsRecorder.assertSingle(AdvancedSettingsEvents.SetTimelineMediaPreviewValue(MediaPreviewValue.Off)) eventsRecorder.assertSingle(AdvancedSettingsEvents.SetTimelineMediaPreviewValue(MediaPreviewValue.Off))
} }
@Test @Test
@Config(qualifiers = "h1080dp") @Config(qualifiers = "h1080dp")
fun `clicking on timeline media preview private rooms emits the expected event`() { fun `clicking on timeline media preview private rooms emits the expected event`() = runAndroidComposeUiTest {
val eventsRecorder = EventsRecorder<AdvancedSettingsEvents>() val eventsRecorder = EventsRecorder<AdvancedSettingsEvents>()
rule.setAdvancedSettingsView( setAdvancedSettingsView(
state = aAdvancedSettingsState( state = aAdvancedSettingsState(
eventSink = eventsRecorder, eventSink = eventsRecorder,
timelineMediaPreviewValue = MediaPreviewValue.On timelineMediaPreviewValue = MediaPreviewValue.On
), ),
) )
rule.clickOn(R.string.screen_advanced_settings_show_media_timeline_private_rooms) clickOn(R.string.screen_advanced_settings_show_media_timeline_private_rooms)
eventsRecorder.assertSingle(AdvancedSettingsEvents.SetTimelineMediaPreviewValue(MediaPreviewValue.Private)) eventsRecorder.assertSingle(AdvancedSettingsEvents.SetTimelineMediaPreviewValue(MediaPreviewValue.Private))
} }
@Test @Test
@Config(qualifiers = "h1080dp") @Config(qualifiers = "h1080dp")
fun `clicking on timeline media preview always show emits the expected event`() { fun `clicking on timeline media preview always show emits the expected event`() = runAndroidComposeUiTest {
val eventsRecorder = EventsRecorder<AdvancedSettingsEvents>() val eventsRecorder = EventsRecorder<AdvancedSettingsEvents>()
rule.setAdvancedSettingsView( setAdvancedSettingsView(
state = aAdvancedSettingsState( state = aAdvancedSettingsState(
eventSink = eventsRecorder, eventSink = eventsRecorder,
timelineMediaPreviewValue = MediaPreviewValue.Off timelineMediaPreviewValue = MediaPreviewValue.Off
), ),
) )
rule.clickOn(R.string.screen_advanced_settings_show_media_timeline_always_show) clickOn(R.string.screen_advanced_settings_show_media_timeline_always_show)
eventsRecorder.assertSingle(AdvancedSettingsEvents.SetTimelineMediaPreviewValue(MediaPreviewValue.On)) eventsRecorder.assertSingle(AdvancedSettingsEvents.SetTimelineMediaPreviewValue(MediaPreviewValue.On))
} }
@Test @Test
@Config(qualifiers = "h1080dp") @Config(qualifiers = "h1080dp")
fun `hide invite avatars toggle is disabled when action is loading`() { fun `hide invite avatars toggle is disabled when action is loading`() = runAndroidComposeUiTest {
val eventsRecorder = EventsRecorder<AdvancedSettingsEvents>(expectEvents = false) val eventsRecorder = EventsRecorder<AdvancedSettingsEvents>(expectEvents = false)
rule.setAdvancedSettingsView( setAdvancedSettingsView(
state = aAdvancedSettingsState( state = aAdvancedSettingsState(
eventSink = eventsRecorder, eventSink = eventsRecorder,
hideInviteAvatars = false, hideInviteAvatars = false,
@ -228,14 +226,14 @@ class AdvancedSettingsViewTest {
), ),
) )
// The toggle should be disabled, so clicking should not emit any events // The toggle should be disabled, so clicking should not emit any events
rule.clickOn(R.string.screen_advanced_settings_hide_invite_avatars_toggle_title) clickOn(R.string.screen_advanced_settings_hide_invite_avatars_toggle_title)
} }
@Test @Test
@Config(qualifiers = "h1080dp") @Config(qualifiers = "h1080dp")
fun `timeline media preview options are disabled when action is loading`() { fun `timeline media preview options are disabled when action is loading`() = runAndroidComposeUiTest {
val eventsRecorder = EventsRecorder<AdvancedSettingsEvents>(expectEvents = false) val eventsRecorder = EventsRecorder<AdvancedSettingsEvents>(expectEvents = false)
rule.setAdvancedSettingsView( setAdvancedSettingsView(
state = aAdvancedSettingsState( state = aAdvancedSettingsState(
eventSink = eventsRecorder, eventSink = eventsRecorder,
timelineMediaPreviewValue = MediaPreviewValue.On, timelineMediaPreviewValue = MediaPreviewValue.On,
@ -243,12 +241,12 @@ class AdvancedSettingsViewTest {
), ),
) )
// The options should be disabled, so clicking should not emit any events // The options should be disabled, so clicking should not emit any events
rule.clickOn(R.string.screen_advanced_settings_show_media_timeline_always_hide) clickOn(R.string.screen_advanced_settings_show_media_timeline_always_hide)
rule.clickOn(R.string.screen_advanced_settings_show_media_timeline_private_rooms) clickOn(R.string.screen_advanced_settings_show_media_timeline_private_rooms)
} }
} }
private fun <R : TestRule> AndroidComposeTestRule<R, ComponentActivity>.setAdvancedSettingsView( private fun AndroidComposeUiTest<ComponentActivity>.setAdvancedSettingsView(
state: AdvancedSettingsState, state: AdvancedSettingsState,
analyticsService: AnalyticsService = FakeAnalyticsService(), analyticsService: AnalyticsService = FakeAnalyticsService(),
onBackClick: () -> Unit = EnsureNeverCalled(), onBackClick: () -> Unit = EnsureNeverCalled(),

View file

@ -6,13 +6,16 @@
* Please see LICENSE files in the repository root for full details. * Please see LICENSE files in the repository root for full details.
*/ */
@file:OptIn(ExperimentalTestApi::class)
package io.element.android.features.preferences.impl.blockedusers package io.element.android.features.preferences.impl.blockedusers
import androidx.activity.ComponentActivity import androidx.activity.ComponentActivity
import androidx.compose.ui.test.junit4.AndroidComposeTestRule import androidx.compose.ui.test.AndroidComposeUiTest
import androidx.compose.ui.test.junit4.createAndroidComposeRule import androidx.compose.ui.test.ExperimentalTestApi
import androidx.compose.ui.test.onNodeWithText import androidx.compose.ui.test.onNodeWithText
import androidx.compose.ui.test.performClick import androidx.compose.ui.test.performClick
import androidx.compose.ui.test.v2.runAndroidComposeUiTest
import androidx.test.ext.junit.runners.AndroidJUnit4 import androidx.test.ext.junit.runners.AndroidJUnit4
import io.element.android.features.preferences.impl.R import io.element.android.features.preferences.impl.R
import io.element.android.libraries.architecture.AsyncAction import io.element.android.libraries.architecture.AsyncAction
@ -23,72 +26,67 @@ import io.element.android.tests.testutils.EventsRecorder
import io.element.android.tests.testutils.clickOn import io.element.android.tests.testutils.clickOn
import io.element.android.tests.testutils.ensureCalledOnce import io.element.android.tests.testutils.ensureCalledOnce
import io.element.android.tests.testutils.pressBack import io.element.android.tests.testutils.pressBack
import org.junit.Rule
import org.junit.Test import org.junit.Test
import org.junit.rules.TestRule
import org.junit.runner.RunWith import org.junit.runner.RunWith
@RunWith(AndroidJUnit4::class) @RunWith(AndroidJUnit4::class)
class BlockedUserViewTest { class BlockedUserViewTest {
@get:Rule
val rule = createAndroidComposeRule<ComponentActivity>()
@Test @Test
fun `clicking on back invokes back callback`() { fun `clicking on back invokes back callback`() = runAndroidComposeUiTest {
val eventsRecorder = EventsRecorder<BlockedUsersEvents>(expectEvents = false) val eventsRecorder = EventsRecorder<BlockedUsersEvents>(expectEvents = false)
ensureCalledOnce { callback -> ensureCalledOnce { callback ->
rule.setBlockedUsersView( setBlockedUsersView(
aBlockedUsersState( aBlockedUsersState(
eventSink = eventsRecorder eventSink = eventsRecorder
), ),
onBackClick = callback, onBackClick = callback,
) )
rule.pressBack() pressBack()
} }
} }
@Test @Test
fun `clicking on a user emits the expected Event`() { fun `clicking on a user emits the expected Event`() = runAndroidComposeUiTest {
val eventsRecorder = EventsRecorder<BlockedUsersEvents>() val eventsRecorder = EventsRecorder<BlockedUsersEvents>()
val userList = aMatrixUserList() val userList = aMatrixUserList()
rule.setBlockedUsersView( setBlockedUsersView(
aBlockedUsersState( aBlockedUsersState(
blockedUsers = userList, blockedUsers = userList,
eventSink = eventsRecorder eventSink = eventsRecorder
), ),
) )
rule.onNodeWithText(userList.first().displayName.orEmpty()).performClick() onNodeWithText(userList.first().displayName.orEmpty()).performClick()
eventsRecorder.assertSingle(BlockedUsersEvents.Unblock(userList.first().userId)) eventsRecorder.assertSingle(BlockedUsersEvents.Unblock(userList.first().userId))
} }
@Test @Test
fun `clicking on cancel sends a BlockedUsersEvents`() { fun `clicking on cancel sends a BlockedUsersEvents`() = runAndroidComposeUiTest {
val eventsRecorder = EventsRecorder<BlockedUsersEvents>() val eventsRecorder = EventsRecorder<BlockedUsersEvents>()
rule.setBlockedUsersView( setBlockedUsersView(
aBlockedUsersState( aBlockedUsersState(
unblockUserAction = AsyncAction.ConfirmingNoParams, unblockUserAction = AsyncAction.ConfirmingNoParams,
eventSink = eventsRecorder eventSink = eventsRecorder
), ),
) )
rule.clickOn(CommonStrings.action_cancel) clickOn(CommonStrings.action_cancel)
eventsRecorder.assertSingle(BlockedUsersEvents.Cancel) eventsRecorder.assertSingle(BlockedUsersEvents.Cancel)
} }
@Test @Test
fun `clicking on confirm sends a BlockedUsersEvents`() { fun `clicking on confirm sends a BlockedUsersEvents`() = runAndroidComposeUiTest {
val eventsRecorder = EventsRecorder<BlockedUsersEvents>() val eventsRecorder = EventsRecorder<BlockedUsersEvents>()
rule.setBlockedUsersView( setBlockedUsersView(
aBlockedUsersState( aBlockedUsersState(
unblockUserAction = AsyncAction.ConfirmingNoParams, unblockUserAction = AsyncAction.ConfirmingNoParams,
eventSink = eventsRecorder eventSink = eventsRecorder
), ),
) )
rule.clickOn(R.string.screen_blocked_users_unblock_alert_action) clickOn(R.string.screen_blocked_users_unblock_alert_action)
eventsRecorder.assertSingle(BlockedUsersEvents.ConfirmUnblock) eventsRecorder.assertSingle(BlockedUsersEvents.ConfirmUnblock)
} }
} }
private fun <R : TestRule> AndroidComposeTestRule<R, ComponentActivity>.setBlockedUsersView( private fun AndroidComposeUiTest<ComponentActivity>.setBlockedUsersView(
state: BlockedUsersState, state: BlockedUsersState,
onBackClick: () -> Unit = EnsureNeverCalled(), onBackClick: () -> Unit = EnsureNeverCalled(),
) { ) {

View file

@ -6,13 +6,16 @@
* Please see LICENSE files in the repository root for full details. * Please see LICENSE files in the repository root for full details.
*/ */
@file:OptIn(ExperimentalTestApi::class)
package io.element.android.features.preferences.impl.developer package io.element.android.features.preferences.impl.developer
import androidx.activity.ComponentActivity import androidx.activity.ComponentActivity
import androidx.compose.ui.test.junit4.AndroidComposeTestRule import androidx.compose.ui.test.AndroidComposeUiTest
import androidx.compose.ui.test.junit4.createAndroidComposeRule import androidx.compose.ui.test.ExperimentalTestApi
import androidx.compose.ui.test.onNodeWithText import androidx.compose.ui.test.onNodeWithText
import androidx.compose.ui.test.performClick import androidx.compose.ui.test.performClick
import androidx.compose.ui.test.v2.runAndroidComposeUiTest
import androidx.test.ext.junit.runners.AndroidJUnit4 import androidx.test.ext.junit.runners.AndroidJUnit4
import io.element.android.features.preferences.impl.R import io.element.android.features.preferences.impl.R
import io.element.android.tests.testutils.EnsureNeverCalled import io.element.android.tests.testutils.EnsureNeverCalled
@ -20,76 +23,71 @@ import io.element.android.tests.testutils.EventsRecorder
import io.element.android.tests.testutils.clickOn import io.element.android.tests.testutils.clickOn
import io.element.android.tests.testutils.ensureCalledOnce import io.element.android.tests.testutils.ensureCalledOnce
import io.element.android.tests.testutils.pressBack import io.element.android.tests.testutils.pressBack
import org.junit.Rule
import org.junit.Test import org.junit.Test
import org.junit.rules.TestRule
import org.junit.runner.RunWith import org.junit.runner.RunWith
import org.robolectric.annotation.Config import org.robolectric.annotation.Config
@RunWith(AndroidJUnit4::class) @RunWith(AndroidJUnit4::class)
class DeveloperSettingsViewTest { class DeveloperSettingsViewTest {
@get:Rule
val rule = createAndroidComposeRule<ComponentActivity>()
@Test @Test
fun `clicking on back invokes the expected callback`() { fun `clicking on back invokes the expected callback`() = runAndroidComposeUiTest {
val eventsRecorder = EventsRecorder<DeveloperSettingsEvents>(expectEvents = false) val eventsRecorder = EventsRecorder<DeveloperSettingsEvents>(expectEvents = false)
ensureCalledOnce { ensureCalledOnce {
rule.setDeveloperSettingsView( setDeveloperSettingsView(
state = aDeveloperSettingsState( state = aDeveloperSettingsState(
eventSink = eventsRecorder eventSink = eventsRecorder
), ),
onBackClick = it onBackClick = it
) )
rule.pressBack() pressBack()
} }
} }
@Config(qualifiers = "h2000dp") @Config(qualifiers = "h2000dp")
@Test @Test
fun `clicking on push history notification invokes the expected callback`() { fun `clicking on push history notification invokes the expected callback`() = runAndroidComposeUiTest {
val eventsRecorder = EventsRecorder<DeveloperSettingsEvents>(expectEvents = false) val eventsRecorder = EventsRecorder<DeveloperSettingsEvents>(expectEvents = false)
ensureCalledOnce { ensureCalledOnce {
rule.setDeveloperSettingsView( setDeveloperSettingsView(
state = aDeveloperSettingsState( state = aDeveloperSettingsState(
eventSink = eventsRecorder eventSink = eventsRecorder
), ),
onPushHistoryClick = it onPushHistoryClick = it
) )
rule.clickOn(R.string.troubleshoot_notifications_entry_point_push_history_title) clickOn(R.string.troubleshoot_notifications_entry_point_push_history_title)
} }
} }
@Config(qualifiers = "h2000dp") @Config(qualifiers = "h2000dp")
@Test @Test
fun `clicking on open showkase invokes the expected callback`() { fun `clicking on open showkase invokes the expected callback`() = runAndroidComposeUiTest {
val eventsRecorder = EventsRecorder<DeveloperSettingsEvents>(expectEvents = false) val eventsRecorder = EventsRecorder<DeveloperSettingsEvents>(expectEvents = false)
ensureCalledOnce { ensureCalledOnce {
rule.setDeveloperSettingsView( setDeveloperSettingsView(
state = aDeveloperSettingsState( state = aDeveloperSettingsState(
eventSink = eventsRecorder eventSink = eventsRecorder
), ),
onOpenShowkase = it onOpenShowkase = it
) )
rule.onNodeWithText("Open Showkase browser").performClick() onNodeWithText("Open Showkase browser").performClick()
} }
} }
@Config(qualifiers = "h2200dp") @Config(qualifiers = "h2200dp")
@Test @Test
fun `clicking on clear cache emits the expected event`() { fun `clicking on clear cache emits the expected event`() = runAndroidComposeUiTest {
val eventsRecorder = EventsRecorder<DeveloperSettingsEvents>() val eventsRecorder = EventsRecorder<DeveloperSettingsEvents>()
rule.setDeveloperSettingsView( setDeveloperSettingsView(
state = aDeveloperSettingsState( state = aDeveloperSettingsState(
eventSink = eventsRecorder eventSink = eventsRecorder
), ),
) )
rule.onNodeWithText("Clear cache").performClick() onNodeWithText("Clear cache").performClick()
eventsRecorder.assertSingle(DeveloperSettingsEvents.ClearCache) eventsRecorder.assertSingle(DeveloperSettingsEvents.ClearCache)
} }
} }
private fun <R : TestRule> AndroidComposeTestRule<R, ComponentActivity>.setDeveloperSettingsView( private fun AndroidComposeUiTest<ComponentActivity>.setDeveloperSettingsView(
state: DeveloperSettingsState, state: DeveloperSettingsState,
onOpenShowkase: () -> Unit = EnsureNeverCalled(), onOpenShowkase: () -> Unit = EnsureNeverCalled(),
onPushHistoryClick: () -> Unit = EnsureNeverCalled(), onPushHistoryClick: () -> Unit = EnsureNeverCalled(),

View file

@ -5,19 +5,22 @@
* Please see LICENSE files in the repository root for full details. * Please see LICENSE files in the repository root for full details.
*/ */
@file:OptIn(ExperimentalTestApi::class)
package io.element.android.features.preferences.impl.developer.appsettings package io.element.android.features.preferences.impl.developer.appsettings
import androidx.activity.ComponentActivity import androidx.activity.ComponentActivity
import androidx.compose.ui.test.AndroidComposeUiTest
import androidx.compose.ui.test.ExperimentalTestApi
import androidx.compose.ui.test.filterToOne import androidx.compose.ui.test.filterToOne
import androidx.compose.ui.test.hasAnyAncestor import androidx.compose.ui.test.hasAnyAncestor
import androidx.compose.ui.test.isDialog import androidx.compose.ui.test.isDialog
import androidx.compose.ui.test.isEditable import androidx.compose.ui.test.isEditable
import androidx.compose.ui.test.isFocusable import androidx.compose.ui.test.isFocusable
import androidx.compose.ui.test.junit4.AndroidComposeTestRule
import androidx.compose.ui.test.junit4.createAndroidComposeRule
import androidx.compose.ui.test.onNodeWithText import androidx.compose.ui.test.onNodeWithText
import androidx.compose.ui.test.performClick import androidx.compose.ui.test.performClick
import androidx.compose.ui.test.performTextInput import androidx.compose.ui.test.performTextInput
import androidx.compose.ui.test.v2.runAndroidComposeUiTest
import androidx.test.ext.junit.runners.AndroidJUnit4 import androidx.test.ext.junit.runners.AndroidJUnit4
import io.element.android.features.preferences.impl.R import io.element.android.features.preferences.impl.R
import io.element.android.features.preferences.impl.developer.tracing.LogLevelItem import io.element.android.features.preferences.impl.developer.tracing.LogLevelItem
@ -27,78 +30,73 @@ import io.element.android.tests.testutils.EventsRecorder
import io.element.android.tests.testutils.clickOn import io.element.android.tests.testutils.clickOn
import io.element.android.tests.testutils.ensureCalledOnce import io.element.android.tests.testutils.ensureCalledOnce
import io.element.android.tests.testutils.pressBack import io.element.android.tests.testutils.pressBack
import org.junit.Rule
import org.junit.Test import org.junit.Test
import org.junit.rules.TestRule
import org.junit.runner.RunWith import org.junit.runner.RunWith
import org.robolectric.annotation.Config import org.robolectric.annotation.Config
@RunWith(AndroidJUnit4::class) @RunWith(AndroidJUnit4::class)
class AppDeveloperSettingsPageTest { class AppDeveloperSettingsPageTest {
@get:Rule
val rule = createAndroidComposeRule<ComponentActivity>()
@Test @Test
fun `clicking on back invokes the expected callback`() { fun `clicking on back invokes the expected callback`() = runAndroidComposeUiTest {
val eventsRecorder = EventsRecorder<AppDeveloperSettingsEvent>(expectEvents = false) val eventsRecorder = EventsRecorder<AppDeveloperSettingsEvent>(expectEvents = false)
ensureCalledOnce { ensureCalledOnce {
rule.setAppDeveloperSettingsView( setAppDeveloperSettingsView(
state = anAppDeveloperSettingsState( state = anAppDeveloperSettingsState(
eventSink = eventsRecorder eventSink = eventsRecorder
), ),
onBackClick = it onBackClick = it
) )
rule.pressBack() pressBack()
} }
} }
@Config(qualifiers = "h1500dp") @Config(qualifiers = "h1500dp")
@Test @Test
fun `clicking on element call url open the dialogs and submit emits the expected event`() { fun `clicking on element call url open the dialogs and submit emits the expected event`() = runAndroidComposeUiTest {
val eventsRecorder = EventsRecorder<AppDeveloperSettingsEvent>() val eventsRecorder = EventsRecorder<AppDeveloperSettingsEvent>()
rule.setAppDeveloperSettingsView( setAppDeveloperSettingsView(
state = anAppDeveloperSettingsState( state = anAppDeveloperSettingsState(
eventSink = eventsRecorder eventSink = eventsRecorder
), ),
) )
rule.clickOn(R.string.screen_advanced_settings_element_call_base_url) clickOn(R.string.screen_advanced_settings_element_call_base_url)
val textInputNode = rule.onAllNodes(isEditable().and(isFocusable())).filterToOne(hasAnyAncestor(isDialog())) val textInputNode = onAllNodes(isEditable().and(isFocusable())).filterToOne(hasAnyAncestor(isDialog()))
textInputNode.performTextInput("https://call.element.dev") textInputNode.performTextInput("https://call.element.dev")
rule.clickOn(CommonStrings.action_ok) clickOn(CommonStrings.action_ok)
eventsRecorder.assertSingle(AppDeveloperSettingsEvent.SetCustomElementCallBaseUrl("https://call.element.dev")) eventsRecorder.assertSingle(AppDeveloperSettingsEvent.SetCustomElementCallBaseUrl("https://call.element.dev"))
} }
@Config(qualifiers = "h2000dp") @Config(qualifiers = "h2000dp")
@Test @Test
fun `clicking on open showkase invokes the expected callback`() { fun `clicking on open showkase invokes the expected callback`() = runAndroidComposeUiTest {
val eventsRecorder = EventsRecorder<AppDeveloperSettingsEvent>(expectEvents = false) val eventsRecorder = EventsRecorder<AppDeveloperSettingsEvent>(expectEvents = false)
ensureCalledOnce { ensureCalledOnce {
rule.setAppDeveloperSettingsView( setAppDeveloperSettingsView(
state = anAppDeveloperSettingsState( state = anAppDeveloperSettingsState(
eventSink = eventsRecorder eventSink = eventsRecorder
), ),
onOpenShowkase = it onOpenShowkase = it
) )
rule.onNodeWithText("Open Showkase browser").performClick() onNodeWithText("Open Showkase browser").performClick()
} }
} }
@Config(qualifiers = "h1024dp") @Config(qualifiers = "h1024dp")
@Test @Test
fun `clicking on log level emits the expected event`() { fun `clicking on log level emits the expected event`() = runAndroidComposeUiTest {
val eventsRecorder = EventsRecorder<AppDeveloperSettingsEvent>() val eventsRecorder = EventsRecorder<AppDeveloperSettingsEvent>()
rule.setAppDeveloperSettingsView( setAppDeveloperSettingsView(
state = anAppDeveloperSettingsState( state = anAppDeveloperSettingsState(
eventSink = eventsRecorder eventSink = eventsRecorder
), ),
) )
rule.onNodeWithText("Tracing log level").performClick() onNodeWithText("Tracing log level").performClick()
rule.onNodeWithText("Debug").performClick() onNodeWithText("Debug").performClick()
eventsRecorder.assertSingle(AppDeveloperSettingsEvent.SetTracingLogLevel(LogLevelItem.DEBUG)) eventsRecorder.assertSingle(AppDeveloperSettingsEvent.SetTracingLogLevel(LogLevelItem.DEBUG))
} }
} }
private fun <R : TestRule> AndroidComposeTestRule<R, ComponentActivity>.setAppDeveloperSettingsView( private fun AndroidComposeUiTest<ComponentActivity>.setAppDeveloperSettingsView(
state: AppDeveloperSettingsState, state: AppDeveloperSettingsState,
onOpenShowkase: () -> Unit = EnsureNeverCalled(), onOpenShowkase: () -> Unit = EnsureNeverCalled(),
onBackClick: () -> Unit = EnsureNeverCalled(), onBackClick: () -> Unit = EnsureNeverCalled(),

View file

@ -6,13 +6,16 @@
* Please see LICENSE files in the repository root for full details. * Please see LICENSE files in the repository root for full details.
*/ */
@file:OptIn(ExperimentalTestApi::class)
package io.element.android.features.preferences.impl.notifications package io.element.android.features.preferences.impl.notifications
import androidx.activity.ComponentActivity import androidx.activity.ComponentActivity
import androidx.compose.ui.test.junit4.AndroidComposeTestRule import androidx.compose.ui.test.AndroidComposeUiTest
import androidx.compose.ui.test.junit4.createAndroidComposeRule import androidx.compose.ui.test.ExperimentalTestApi
import androidx.compose.ui.test.onNodeWithText import androidx.compose.ui.test.onNodeWithText
import androidx.compose.ui.test.performClick import androidx.compose.ui.test.performClick
import androidx.compose.ui.test.v2.runAndroidComposeUiTest
import androidx.test.ext.junit.runners.AndroidJUnit4 import androidx.test.ext.junit.runners.AndroidJUnit4
import io.element.android.features.preferences.impl.R import io.element.android.features.preferences.impl.R
import io.element.android.libraries.architecture.AsyncAction import io.element.android.libraries.architecture.AsyncAction
@ -25,76 +28,71 @@ import io.element.android.tests.testutils.clickOn
import io.element.android.tests.testutils.ensureCalledOnce import io.element.android.tests.testutils.ensureCalledOnce
import io.element.android.tests.testutils.ensureCalledOnceWithParam import io.element.android.tests.testutils.ensureCalledOnceWithParam
import io.element.android.tests.testutils.pressBack import io.element.android.tests.testutils.pressBack
import org.junit.Rule
import org.junit.Test import org.junit.Test
import org.junit.rules.TestRule
import org.junit.runner.RunWith import org.junit.runner.RunWith
import org.robolectric.annotation.Config import org.robolectric.annotation.Config
@RunWith(AndroidJUnit4::class) @RunWith(AndroidJUnit4::class)
class NotificationSettingsViewTest { class NotificationSettingsViewTest {
@get:Rule
val rule = createAndroidComposeRule<ComponentActivity>()
@Test @Test
fun `clicking on back invokes the expected callback`() { fun `clicking on back invokes the expected callback`() = runAndroidComposeUiTest {
val eventsRecorder = EventsRecorder<NotificationSettingsEvents>() val eventsRecorder = EventsRecorder<NotificationSettingsEvents>()
ensureCalledOnce { ensureCalledOnce {
rule.setNotificationSettingsView( setNotificationSettingsView(
state = aValidNotificationSettingsState( state = aValidNotificationSettingsState(
eventSink = eventsRecorder eventSink = eventsRecorder
), ),
onBackClick = it onBackClick = it
) )
rule.pressBack() pressBack()
} }
eventsRecorder.assertSingle(NotificationSettingsEvents.RefreshSystemNotificationsEnabled) eventsRecorder.assertSingle(NotificationSettingsEvents.RefreshSystemNotificationsEnabled)
} }
@Config(qualifiers = "h1024dp") @Config(qualifiers = "h1024dp")
@Test @Test
fun `clicking on troubleshoot notification invokes the expected callback`() { fun `clicking on troubleshoot notification invokes the expected callback`() = runAndroidComposeUiTest {
val eventsRecorder = EventsRecorder<NotificationSettingsEvents>() val eventsRecorder = EventsRecorder<NotificationSettingsEvents>()
ensureCalledOnce { ensureCalledOnce {
rule.setNotificationSettingsView( setNotificationSettingsView(
state = aValidNotificationSettingsState( state = aValidNotificationSettingsState(
eventSink = eventsRecorder eventSink = eventsRecorder
), ),
onTroubleshootNotificationsClick = it onTroubleshootNotificationsClick = it
) )
rule.clickOn(R.string.troubleshoot_notifications_entry_point_title) clickOn(R.string.troubleshoot_notifications_entry_point_title)
} }
eventsRecorder.assertSingle(NotificationSettingsEvents.RefreshSystemNotificationsEnabled) eventsRecorder.assertSingle(NotificationSettingsEvents.RefreshSystemNotificationsEnabled)
} }
@Config(qualifiers = "h1024dp") @Config(qualifiers = "h1024dp")
@Test @Test
fun `clicking on group chats invokes the expected callback`() { fun `clicking on group chats invokes the expected callback`() = runAndroidComposeUiTest {
val eventsRecorder = EventsRecorder<NotificationSettingsEvents>() val eventsRecorder = EventsRecorder<NotificationSettingsEvents>()
ensureCalledOnceWithParam(false) { ensureCalledOnceWithParam(false) {
rule.setNotificationSettingsView( setNotificationSettingsView(
state = aValidNotificationSettingsState( state = aValidNotificationSettingsState(
eventSink = eventsRecorder eventSink = eventsRecorder
), ),
onOpenEditDefault = it onOpenEditDefault = it
) )
rule.clickOn(R.string.screen_notification_settings_group_chats) clickOn(R.string.screen_notification_settings_group_chats)
} }
eventsRecorder.assertSingle(NotificationSettingsEvents.RefreshSystemNotificationsEnabled) eventsRecorder.assertSingle(NotificationSettingsEvents.RefreshSystemNotificationsEnabled)
} }
@Config(qualifiers = "h1024dp") @Config(qualifiers = "h1024dp")
@Test @Test
fun `clicking on direct chats invokes the expected callback`() { fun `clicking on direct chats invokes the expected callback`() = runAndroidComposeUiTest {
val eventsRecorder = EventsRecorder<NotificationSettingsEvents>() val eventsRecorder = EventsRecorder<NotificationSettingsEvents>()
ensureCalledOnceWithParam(true) { ensureCalledOnceWithParam(true) {
rule.setNotificationSettingsView( setNotificationSettingsView(
state = aValidNotificationSettingsState( state = aValidNotificationSettingsState(
eventSink = eventsRecorder eventSink = eventsRecorder
), ),
onOpenEditDefault = it onOpenEditDefault = it
) )
rule.clickOn(R.string.screen_notification_settings_direct_chats) clickOn(R.string.screen_notification_settings_direct_chats)
} }
eventsRecorder.assertSingle(NotificationSettingsEvents.RefreshSystemNotificationsEnabled) eventsRecorder.assertSingle(NotificationSettingsEvents.RefreshSystemNotificationsEnabled)
} }
@ -111,15 +109,15 @@ class NotificationSettingsViewTest {
testNotificationToggle(false) testNotificationToggle(false)
} }
private fun testNotificationToggle(initialState: Boolean) { private fun testNotificationToggle(initialState: Boolean) = runAndroidComposeUiTest {
val eventsRecorder = EventsRecorder<NotificationSettingsEvents>() val eventsRecorder = EventsRecorder<NotificationSettingsEvents>()
rule.setNotificationSettingsView( setNotificationSettingsView(
state = aValidNotificationSettingsState( state = aValidNotificationSettingsState(
appNotificationEnabled = initialState, appNotificationEnabled = initialState,
eventSink = eventsRecorder eventSink = eventsRecorder
), ),
) )
rule.clickOn(R.string.screen_notification_settings_enable_notifications) clickOn(R.string.screen_notification_settings_enable_notifications)
eventsRecorder.assertList( eventsRecorder.assertList(
listOf( listOf(
NotificationSettingsEvents.RefreshSystemNotificationsEnabled, NotificationSettingsEvents.RefreshSystemNotificationsEnabled,
@ -140,15 +138,15 @@ class NotificationSettingsViewTest {
testAtRoomToggle(false) testAtRoomToggle(false)
} }
private fun testAtRoomToggle(initialState: Boolean) { private fun testAtRoomToggle(initialState: Boolean) = runAndroidComposeUiTest {
val eventsRecorder = EventsRecorder<NotificationSettingsEvents>() val eventsRecorder = EventsRecorder<NotificationSettingsEvents>()
rule.setNotificationSettingsView( setNotificationSettingsView(
state = aValidNotificationSettingsState( state = aValidNotificationSettingsState(
atRoomNotificationsEnabled = initialState, atRoomNotificationsEnabled = initialState,
eventSink = eventsRecorder eventSink = eventsRecorder
), ),
) )
rule.clickOn(R.string.screen_notification_settings_room_mention_label) clickOn(R.string.screen_notification_settings_room_mention_label)
eventsRecorder.assertList( eventsRecorder.assertList(
listOf( listOf(
NotificationSettingsEvents.RefreshSystemNotificationsEnabled, NotificationSettingsEvents.RefreshSystemNotificationsEnabled,
@ -169,15 +167,15 @@ class NotificationSettingsViewTest {
testInvitationToggle(false) testInvitationToggle(false)
} }
private fun testInvitationToggle(initialState: Boolean) { private fun testInvitationToggle(initialState: Boolean) = runAndroidComposeUiTest {
val eventsRecorder = EventsRecorder<NotificationSettingsEvents>() val eventsRecorder = EventsRecorder<NotificationSettingsEvents>()
rule.setNotificationSettingsView( setNotificationSettingsView(
state = aValidNotificationSettingsState( state = aValidNotificationSettingsState(
inviteForMeNotificationsEnabled = initialState, inviteForMeNotificationsEnabled = initialState,
eventSink = eventsRecorder eventSink = eventsRecorder
), ),
) )
rule.clickOn(R.string.screen_notification_settings_invite_for_me_label) clickOn(R.string.screen_notification_settings_invite_for_me_label)
eventsRecorder.assertList( eventsRecorder.assertList(
listOf( listOf(
NotificationSettingsEvents.RefreshSystemNotificationsEnabled, NotificationSettingsEvents.RefreshSystemNotificationsEnabled,
@ -188,15 +186,15 @@ class NotificationSettingsViewTest {
@Config(qualifiers = "h1024dp") @Config(qualifiers = "h1024dp")
@Test @Test
fun `with an error configuration, clicking on continue emits the expected events`() { fun `with an error configuration, clicking on continue emits the expected events`() = runAndroidComposeUiTest {
val eventsRecorder = EventsRecorder<NotificationSettingsEvents>() val eventsRecorder = EventsRecorder<NotificationSettingsEvents>()
rule.setNotificationSettingsView( setNotificationSettingsView(
state = aValidNotificationSettingsState( state = aValidNotificationSettingsState(
changeNotificationSettingAction = AsyncAction.Failure(AN_EXCEPTION), changeNotificationSettingAction = AsyncAction.Failure(AN_EXCEPTION),
eventSink = eventsRecorder eventSink = eventsRecorder
), ),
) )
rule.clickOn(CommonStrings.action_ok) clickOn(CommonStrings.action_ok)
eventsRecorder.assertList( eventsRecorder.assertList(
listOf( listOf(
NotificationSettingsEvents.RefreshSystemNotificationsEnabled, NotificationSettingsEvents.RefreshSystemNotificationsEnabled,
@ -207,15 +205,15 @@ class NotificationSettingsViewTest {
@Config(qualifiers = "h1024dp") @Config(qualifiers = "h1024dp")
@Test @Test
fun `with invalid configuration, clicking on continue emits the expected events`() { fun `with invalid configuration, clicking on continue emits the expected events`() = runAndroidComposeUiTest {
val eventsRecorder = EventsRecorder<NotificationSettingsEvents>() val eventsRecorder = EventsRecorder<NotificationSettingsEvents>()
rule.setNotificationSettingsView( setNotificationSettingsView(
state = aInvalidNotificationSettingsState( state = aInvalidNotificationSettingsState(
fixFailed = false, fixFailed = false,
eventSink = eventsRecorder eventSink = eventsRecorder
), ),
) )
rule.clickOn(CommonStrings.action_continue) clickOn(CommonStrings.action_continue)
eventsRecorder.assertList( eventsRecorder.assertList(
listOf( listOf(
NotificationSettingsEvents.RefreshSystemNotificationsEnabled, NotificationSettingsEvents.RefreshSystemNotificationsEnabled,
@ -226,15 +224,15 @@ class NotificationSettingsViewTest {
@Config(qualifiers = "h1024dp") @Config(qualifiers = "h1024dp")
@Test @Test
fun `with invalid configuration and error, clicking on OK emits the expected events`() { fun `with invalid configuration and error, clicking on OK emits the expected events`() = runAndroidComposeUiTest {
val eventsRecorder = EventsRecorder<NotificationSettingsEvents>() val eventsRecorder = EventsRecorder<NotificationSettingsEvents>()
rule.setNotificationSettingsView( setNotificationSettingsView(
state = aInvalidNotificationSettingsState( state = aInvalidNotificationSettingsState(
fixFailed = true, fixFailed = true,
eventSink = eventsRecorder eventSink = eventsRecorder
), ),
) )
rule.clickOn(CommonStrings.action_ok) clickOn(CommonStrings.action_ok)
eventsRecorder.assertList( eventsRecorder.assertList(
listOf( listOf(
NotificationSettingsEvents.RefreshSystemNotificationsEnabled, NotificationSettingsEvents.RefreshSystemNotificationsEnabled,
@ -245,14 +243,14 @@ class NotificationSettingsViewTest {
@Config(qualifiers = "h1024dp") @Config(qualifiers = "h1024dp")
@Test @Test
fun `clicking on Push notification provider emits the expected event`() { fun `clicking on Push notification provider emits the expected event`() = runAndroidComposeUiTest {
val eventsRecorder = EventsRecorder<NotificationSettingsEvents>() val eventsRecorder = EventsRecorder<NotificationSettingsEvents>()
rule.setNotificationSettingsView( setNotificationSettingsView(
state = aValidNotificationSettingsState( state = aValidNotificationSettingsState(
eventSink = eventsRecorder eventSink = eventsRecorder
), ),
) )
rule.clickOn(R.string.screen_advanced_settings_push_provider_android) clickOn(R.string.screen_advanced_settings_push_provider_android)
eventsRecorder.assertList( eventsRecorder.assertList(
listOf( listOf(
NotificationSettingsEvents.RefreshSystemNotificationsEnabled, NotificationSettingsEvents.RefreshSystemNotificationsEnabled,
@ -262,16 +260,16 @@ class NotificationSettingsViewTest {
} }
@Test @Test
fun `clicking on a push provider emits the expected event`() { fun `clicking on a push provider emits the expected event`() = runAndroidComposeUiTest {
val eventsRecorder = EventsRecorder<NotificationSettingsEvents>() val eventsRecorder = EventsRecorder<NotificationSettingsEvents>()
rule.setNotificationSettingsView( setNotificationSettingsView(
state = aValidNotificationSettingsState( state = aValidNotificationSettingsState(
eventSink = eventsRecorder, eventSink = eventsRecorder,
showChangePushProviderDialog = true, showChangePushProviderDialog = true,
availablePushDistributors = listOf(aDistributor("P1"), aDistributor("P2")) availablePushDistributors = listOf(aDistributor("P1"), aDistributor("P2"))
), ),
) )
rule.onNodeWithText("P2").performClick() onNodeWithText("P2").performClick()
eventsRecorder.assertList( eventsRecorder.assertList(
listOf( listOf(
NotificationSettingsEvents.RefreshSystemNotificationsEnabled, NotificationSettingsEvents.RefreshSystemNotificationsEnabled,
@ -281,7 +279,7 @@ class NotificationSettingsViewTest {
} }
} }
private fun <R : TestRule> AndroidComposeTestRule<R, ComponentActivity>.setNotificationSettingsView( private fun AndroidComposeUiTest<ComponentActivity>.setNotificationSettingsView(
state: NotificationSettingsState, state: NotificationSettingsState,
onOpenEditDefault: (isOneToOne: Boolean) -> Unit = EnsureNeverCalledWithParam(), onOpenEditDefault: (isOneToOne: Boolean) -> Unit = EnsureNeverCalledWithParam(),
onTroubleshootNotificationsClick: () -> Unit = EnsureNeverCalled(), onTroubleshootNotificationsClick: () -> Unit = EnsureNeverCalled(),

View file

@ -5,13 +5,16 @@
* Please see LICENSE files in the repository root for full details. * Please see LICENSE files in the repository root for full details.
*/ */
@file:OptIn(ExperimentalTestApi::class)
package io.element.android.features.preferences.impl.root package io.element.android.features.preferences.impl.root
import androidx.activity.ComponentActivity import androidx.activity.ComponentActivity
import androidx.compose.ui.test.junit4.AndroidComposeTestRule import androidx.compose.ui.test.AndroidComposeUiTest
import androidx.compose.ui.test.junit4.createAndroidComposeRule import androidx.compose.ui.test.ExperimentalTestApi
import androidx.compose.ui.test.onNodeWithText import androidx.compose.ui.test.onNodeWithText
import androidx.compose.ui.test.performClick import androidx.compose.ui.test.performClick
import androidx.compose.ui.test.v2.runAndroidComposeUiTest
import androidx.test.ext.junit.runners.AndroidJUnit4 import androidx.test.ext.junit.runners.AndroidJUnit4
import io.element.android.features.preferences.impl.R import io.element.android.features.preferences.impl.R
import io.element.android.libraries.matrix.api.user.MatrixUser import io.element.android.libraries.matrix.api.user.MatrixUser
@ -25,49 +28,45 @@ import io.element.android.tests.testutils.clickOn
import io.element.android.tests.testutils.ensureCalledOnce import io.element.android.tests.testutils.ensureCalledOnce
import io.element.android.tests.testutils.ensureCalledOnceWithParam import io.element.android.tests.testutils.ensureCalledOnceWithParam
import io.element.android.tests.testutils.pressBack import io.element.android.tests.testutils.pressBack
import org.junit.Rule
import org.junit.Test import org.junit.Test
import org.junit.rules.TestRule
import org.junit.runner.RunWith import org.junit.runner.RunWith
@RunWith(AndroidJUnit4::class) @RunWith(AndroidJUnit4::class)
class PreferencesRootViewTest { class PreferencesRootViewTest {
@get:Rule val rule = createAndroidComposeRule<ComponentActivity>()
@Test @Test
fun `clicking on back invokes back callback`() { fun `clicking on back invokes back callback`() = runAndroidComposeUiTest {
val eventsRecorder = EventsRecorder<PreferencesRootEvent>(expectEvents = false) val eventsRecorder = EventsRecorder<PreferencesRootEvent>(expectEvents = false)
ensureCalledOnce { callback -> ensureCalledOnce { callback ->
rule.setView( setView(
aPreferencesRootState( aPreferencesRootState(
eventSink = eventsRecorder eventSink = eventsRecorder
), ),
onBackClick = callback, onBackClick = callback,
) )
rule.pressBack() pressBack()
} }
} }
@Test @Test
fun `click on User profile invokes the expected callback`() { fun `click on User profile invokes the expected callback`() = runAndroidComposeUiTest {
val eventsRecorder = EventsRecorder<PreferencesRootEvent>(expectEvents = false) val eventsRecorder = EventsRecorder<PreferencesRootEvent>(expectEvents = false)
val user = aMatrixUser() val user = aMatrixUser()
ensureCalledOnceWithParam(user) { callback -> ensureCalledOnceWithParam(user) { callback ->
rule.setView( setView(
aPreferencesRootState( aPreferencesRootState(
myUser = user, myUser = user,
eventSink = eventsRecorder, eventSink = eventsRecorder,
), ),
onOpenUserProfile = callback, onOpenUserProfile = callback,
) )
rule.onNodeWithText("Alice").performClick() onNodeWithText("Alice").performClick()
} }
} }
@Test @Test
fun `clicking on other session sends a SwitchToSession`() { fun `clicking on other session sends a SwitchToSession`() = runAndroidComposeUiTest {
val eventsRecorder = EventsRecorder<PreferencesRootEvent>() val eventsRecorder = EventsRecorder<PreferencesRootEvent>()
rule.setView( setView(
aPreferencesRootState( aPreferencesRootState(
isMultiAccountEnabled = true, isMultiAccountEnabled = true,
otherSessions = listOf( otherSessions = listOf(
@ -79,366 +78,366 @@ class PreferencesRootViewTest {
eventSink = eventsRecorder, eventSink = eventsRecorder,
), ),
) )
rule.onNodeWithText("Bob").performClick() onNodeWithText("Bob").performClick()
eventsRecorder.assertSingle(PreferencesRootEvent.SwitchToSession(A_USER_ID_2)) eventsRecorder.assertSingle(PreferencesRootEvent.SwitchToSession(A_USER_ID_2))
} }
@Test @Test
fun `click on Add account invokes the expected callback`() { fun `click on Add account invokes the expected callback`() = runAndroidComposeUiTest {
val eventsRecorder = EventsRecorder<PreferencesRootEvent>(expectEvents = false) val eventsRecorder = EventsRecorder<PreferencesRootEvent>(expectEvents = false)
ensureCalledOnce { callback -> ensureCalledOnce { callback ->
rule.setView( setView(
aPreferencesRootState( aPreferencesRootState(
isMultiAccountEnabled = true, isMultiAccountEnabled = true,
eventSink = eventsRecorder, eventSink = eventsRecorder,
), ),
onAddAccountClick = callback, onAddAccountClick = callback,
) )
rule.clickOn(CommonStrings.common_add_another_account) clickOn(CommonStrings.common_add_another_account)
} }
} }
@Test @Test
fun `when multi account is not enabled, item is not shown`() { fun `when multi account is not enabled, item is not shown`() = runAndroidComposeUiTest {
val eventsRecorder = EventsRecorder<PreferencesRootEvent>(expectEvents = false) val eventsRecorder = EventsRecorder<PreferencesRootEvent>(expectEvents = false)
rule.setView( setView(
aPreferencesRootState( aPreferencesRootState(
isMultiAccountEnabled = false, isMultiAccountEnabled = false,
eventSink = eventsRecorder, eventSink = eventsRecorder,
), ),
) )
rule.onNodeWithText(rule.activity.getString(CommonStrings.common_add_another_account)).assertDoesNotExist() onNodeWithText(activity!!.getString(CommonStrings.common_add_another_account)).assertDoesNotExist()
} }
@Test @Test
fun `click on Encryption invokes the expected callback`() { fun `click on Encryption invokes the expected callback`() = runAndroidComposeUiTest {
val eventsRecorder = EventsRecorder<PreferencesRootEvent>(expectEvents = false) val eventsRecorder = EventsRecorder<PreferencesRootEvent>(expectEvents = false)
ensureCalledOnce { callback -> ensureCalledOnce { callback ->
rule.setView( setView(
aPreferencesRootState( aPreferencesRootState(
showSecureBackup = true, showSecureBackup = true,
eventSink = eventsRecorder, eventSink = eventsRecorder,
), ),
onSecureBackupClick = callback, onSecureBackupClick = callback,
) )
rule.clickOn(CommonStrings.common_encryption) clickOn(CommonStrings.common_encryption)
} }
} }
@Test @Test
fun `when showSecureBackup is false, item is not shown`() { fun `when showSecureBackup is false, item is not shown`() = runAndroidComposeUiTest {
val eventsRecorder = EventsRecorder<PreferencesRootEvent>(expectEvents = false) val eventsRecorder = EventsRecorder<PreferencesRootEvent>(expectEvents = false)
rule.setView( setView(
aPreferencesRootState( aPreferencesRootState(
showSecureBackup = false, showSecureBackup = false,
eventSink = eventsRecorder, eventSink = eventsRecorder,
), ),
) )
rule.onNodeWithText(rule.activity.getString(CommonStrings.common_encryption)).assertDoesNotExist() onNodeWithText(activity!!.getString(CommonStrings.common_encryption)).assertDoesNotExist()
} }
@Test @Test
fun `click on Manage account invokes the expected callback`() { fun `click on Manage account invokes the expected callback`() = runAndroidComposeUiTest {
val eventsRecorder = EventsRecorder<PreferencesRootEvent>(expectEvents = false) val eventsRecorder = EventsRecorder<PreferencesRootEvent>(expectEvents = false)
ensureCalledOnceWithParam("aUrl") { callback -> ensureCalledOnceWithParam("aUrl") { callback ->
rule.setView( setView(
aPreferencesRootState( aPreferencesRootState(
accountManagementUrl = "aUrl", accountManagementUrl = "aUrl",
eventSink = eventsRecorder, eventSink = eventsRecorder,
), ),
onManageAccountClick = callback, onManageAccountClick = callback,
) )
rule.clickOn(CommonStrings.action_manage_account_and_devices) clickOn(CommonStrings.action_manage_account_and_devices)
} }
} }
@Test @Test
fun `when accountManagementUrl is null, item is not shown`() { fun `when accountManagementUrl is null, item is not shown`() = runAndroidComposeUiTest {
val eventsRecorder = EventsRecorder<PreferencesRootEvent>(expectEvents = false) val eventsRecorder = EventsRecorder<PreferencesRootEvent>(expectEvents = false)
rule.setView( setView(
aPreferencesRootState( aPreferencesRootState(
accountManagementUrl = null, accountManagementUrl = null,
eventSink = eventsRecorder, eventSink = eventsRecorder,
), ),
) )
rule.onNodeWithText(rule.activity.getString(CommonStrings.action_manage_account_and_devices)).assertDoesNotExist() onNodeWithText(activity!!.getString(CommonStrings.action_manage_account_and_devices)).assertDoesNotExist()
} }
@Test @Test
fun `click on Link new devices invokes the expected callback`() { fun `click on Link new devices invokes the expected callback`() = runAndroidComposeUiTest {
val eventsRecorder = EventsRecorder<PreferencesRootEvent>(expectEvents = false) val eventsRecorder = EventsRecorder<PreferencesRootEvent>(expectEvents = false)
ensureCalledOnce { callback -> ensureCalledOnce { callback ->
rule.setView( setView(
aPreferencesRootState( aPreferencesRootState(
showLinkNewDevice = true, showLinkNewDevice = true,
eventSink = eventsRecorder, eventSink = eventsRecorder,
), ),
onLinkNewDeviceClick = callback, onLinkNewDeviceClick = callback,
) )
rule.clickOn(CommonStrings.common_link_new_device) clickOn(CommonStrings.common_link_new_device)
} }
} }
@Test @Test
fun `when showLinkNewDevice is false, item is not shown`() { fun `when showLinkNewDevice is false, item is not shown`() = runAndroidComposeUiTest {
val eventsRecorder = EventsRecorder<PreferencesRootEvent>(expectEvents = false) val eventsRecorder = EventsRecorder<PreferencesRootEvent>(expectEvents = false)
rule.setView( setView(
aPreferencesRootState( aPreferencesRootState(
showLinkNewDevice = false, showLinkNewDevice = false,
eventSink = eventsRecorder, eventSink = eventsRecorder,
), ),
) )
rule.onNodeWithText(rule.activity.getString(CommonStrings.common_link_new_device)).assertDoesNotExist() onNodeWithText(activity!!.getString(CommonStrings.common_link_new_device)).assertDoesNotExist()
} }
@Test @Test
fun `click on Analytics invokes the expected callback`() { fun `click on Analytics invokes the expected callback`() = runAndroidComposeUiTest {
val eventsRecorder = EventsRecorder<PreferencesRootEvent>(expectEvents = false) val eventsRecorder = EventsRecorder<PreferencesRootEvent>(expectEvents = false)
ensureCalledOnce { callback -> ensureCalledOnce { callback ->
rule.setView( setView(
aPreferencesRootState( aPreferencesRootState(
showAnalyticsSettings = true, showAnalyticsSettings = true,
eventSink = eventsRecorder, eventSink = eventsRecorder,
), ),
onOpenAnalytics = callback, onOpenAnalytics = callback,
) )
rule.clickOn(CommonStrings.common_analytics) clickOn(CommonStrings.common_analytics)
} }
} }
@Test @Test
fun `when showAnalyticsSettings is false, item is not shown`() { fun `when showAnalyticsSettings is false, item is not shown`() = runAndroidComposeUiTest {
val eventsRecorder = EventsRecorder<PreferencesRootEvent>(expectEvents = false) val eventsRecorder = EventsRecorder<PreferencesRootEvent>(expectEvents = false)
rule.setView( setView(
aPreferencesRootState( aPreferencesRootState(
showAnalyticsSettings = false, showAnalyticsSettings = false,
eventSink = eventsRecorder, eventSink = eventsRecorder,
), ),
) )
rule.onNodeWithText(rule.activity.getString(CommonStrings.common_analytics)).assertDoesNotExist() onNodeWithText(activity!!.getString(CommonStrings.common_analytics)).assertDoesNotExist()
} }
@Test @Test
fun `click on Report a problem invokes the expected callback`() { fun `click on Report a problem invokes the expected callback`() = runAndroidComposeUiTest {
val eventsRecorder = EventsRecorder<PreferencesRootEvent>(expectEvents = false) val eventsRecorder = EventsRecorder<PreferencesRootEvent>(expectEvents = false)
ensureCalledOnce { callback -> ensureCalledOnce { callback ->
rule.setView( setView(
aPreferencesRootState( aPreferencesRootState(
canReportBug = true, canReportBug = true,
eventSink = eventsRecorder, eventSink = eventsRecorder,
), ),
onOpenRageShake = callback, onOpenRageShake = callback,
) )
rule.clickOn(CommonStrings.common_report_a_problem) clickOn(CommonStrings.common_report_a_problem)
} }
} }
@Test @Test
fun `when canReportBug is false, item is not shown`() { fun `when canReportBug is false, item is not shown`() = runAndroidComposeUiTest {
val eventsRecorder = EventsRecorder<PreferencesRootEvent>(expectEvents = false) val eventsRecorder = EventsRecorder<PreferencesRootEvent>(expectEvents = false)
rule.setView( setView(
aPreferencesRootState( aPreferencesRootState(
canReportBug = false, canReportBug = false,
eventSink = eventsRecorder, eventSink = eventsRecorder,
), ),
) )
rule.onNodeWithText(rule.activity.getString(CommonStrings.common_report_a_problem)).assertDoesNotExist() onNodeWithText(activity!!.getString(CommonStrings.common_report_a_problem)).assertDoesNotExist()
} }
@Test @Test
fun `click on Screen lock invokes the expected callback`() { fun `click on Screen lock invokes the expected callback`() = runAndroidComposeUiTest {
val eventsRecorder = EventsRecorder<PreferencesRootEvent>(expectEvents = false) val eventsRecorder = EventsRecorder<PreferencesRootEvent>(expectEvents = false)
ensureCalledOnce { callback -> ensureCalledOnce { callback ->
rule.setView( setView(
aPreferencesRootState( aPreferencesRootState(
eventSink = eventsRecorder, eventSink = eventsRecorder,
), ),
onOpenLockScreenSettings = callback, onOpenLockScreenSettings = callback,
) )
rule.clickOn(CommonStrings.common_screen_lock) clickOn(CommonStrings.common_screen_lock)
} }
} }
@Test @Test
fun `click on About invokes the expected callback`() { fun `click on About invokes the expected callback`() = runAndroidComposeUiTest {
val eventsRecorder = EventsRecorder<PreferencesRootEvent>(expectEvents = false) val eventsRecorder = EventsRecorder<PreferencesRootEvent>(expectEvents = false)
ensureCalledOnce { callback -> ensureCalledOnce { callback ->
rule.setView( setView(
aPreferencesRootState( aPreferencesRootState(
eventSink = eventsRecorder, eventSink = eventsRecorder,
), ),
onOpenAbout = callback, onOpenAbout = callback,
) )
rule.clickOn(CommonStrings.common_about) clickOn(CommonStrings.common_about)
} }
} }
@Test @Test
fun `click on Developer settings invokes the expected callback`() { fun `click on Developer settings invokes the expected callback`() = runAndroidComposeUiTest {
val eventsRecorder = EventsRecorder<PreferencesRootEvent>(expectEvents = false) val eventsRecorder = EventsRecorder<PreferencesRootEvent>(expectEvents = false)
ensureCalledOnce { callback -> ensureCalledOnce { callback ->
rule.setView( setView(
aPreferencesRootState( aPreferencesRootState(
showDeveloperSettings = true, showDeveloperSettings = true,
eventSink = eventsRecorder, eventSink = eventsRecorder,
), ),
onOpenDeveloperSettings = callback, onOpenDeveloperSettings = callback,
) )
rule.clickOn(CommonStrings.common_developer_options) clickOn(CommonStrings.common_developer_options)
} }
} }
@Test @Test
fun `when showDeveloperSettings is false, item is not shown`() { fun `when showDeveloperSettings is false, item is not shown`() = runAndroidComposeUiTest {
val eventsRecorder = EventsRecorder<PreferencesRootEvent>(expectEvents = false) val eventsRecorder = EventsRecorder<PreferencesRootEvent>(expectEvents = false)
rule.setView( setView(
aPreferencesRootState( aPreferencesRootState(
showDeveloperSettings = false, showDeveloperSettings = false,
eventSink = eventsRecorder, eventSink = eventsRecorder,
), ),
) )
rule.onNodeWithText(rule.activity.getString(CommonStrings.common_developer_options)).assertDoesNotExist() onNodeWithText(activity!!.getString(CommonStrings.common_developer_options)).assertDoesNotExist()
} }
@Test @Test
fun `click on Advanced settings invokes the expected callback`() { fun `click on Advanced settings invokes the expected callback`() = runAndroidComposeUiTest {
val eventsRecorder = EventsRecorder<PreferencesRootEvent>(expectEvents = false) val eventsRecorder = EventsRecorder<PreferencesRootEvent>(expectEvents = false)
ensureCalledOnce { callback -> ensureCalledOnce { callback ->
rule.setView( setView(
aPreferencesRootState( aPreferencesRootState(
eventSink = eventsRecorder, eventSink = eventsRecorder,
), ),
onOpenAdvancedSettings = callback, onOpenAdvancedSettings = callback,
) )
rule.clickOn(CommonStrings.common_advanced_settings) clickOn(CommonStrings.common_advanced_settings)
} }
} }
@Test @Test
fun `click on Labs invokes the expected callback`() { fun `click on Labs invokes the expected callback`() = runAndroidComposeUiTest {
val eventsRecorder = EventsRecorder<PreferencesRootEvent>(expectEvents = false) val eventsRecorder = EventsRecorder<PreferencesRootEvent>(expectEvents = false)
ensureCalledOnce { callback -> ensureCalledOnce { callback ->
rule.setView( setView(
aPreferencesRootState( aPreferencesRootState(
showLabsItem = true, showLabsItem = true,
eventSink = eventsRecorder, eventSink = eventsRecorder,
), ),
onOpenLabs = callback, onOpenLabs = callback,
) )
rule.clickOn(R.string.screen_labs_title) clickOn(R.string.screen_labs_title)
} }
} }
@Test @Test
fun `when showLabsItem is false, item is not shown`() { fun `when showLabsItem is false, item is not shown`() = runAndroidComposeUiTest {
val eventsRecorder = EventsRecorder<PreferencesRootEvent>(expectEvents = false) val eventsRecorder = EventsRecorder<PreferencesRootEvent>(expectEvents = false)
rule.setView( setView(
aPreferencesRootState( aPreferencesRootState(
showLabsItem = false, showLabsItem = false,
eventSink = eventsRecorder, eventSink = eventsRecorder,
), ),
) )
rule.onNodeWithText(rule.activity.getString(R.string.screen_labs_title)).assertDoesNotExist() onNodeWithText(activity!!.getString(R.string.screen_labs_title)).assertDoesNotExist()
} }
@Test @Test
fun `click on Notification invokes the expected callback`() { fun `click on Notification invokes the expected callback`() = runAndroidComposeUiTest {
val eventsRecorder = EventsRecorder<PreferencesRootEvent>(expectEvents = false) val eventsRecorder = EventsRecorder<PreferencesRootEvent>(expectEvents = false)
ensureCalledOnce { callback -> ensureCalledOnce { callback ->
rule.setView( setView(
aPreferencesRootState( aPreferencesRootState(
eventSink = eventsRecorder, eventSink = eventsRecorder,
), ),
onOpenNotificationSettings = callback, onOpenNotificationSettings = callback,
) )
rule.clickOn(R.string.screen_notification_settings_title) clickOn(R.string.screen_notification_settings_title)
} }
} }
@Test @Test
fun `click on Blocked users invokes the expected callback`() { fun `click on Blocked users invokes the expected callback`() = runAndroidComposeUiTest {
val eventsRecorder = EventsRecorder<PreferencesRootEvent>(expectEvents = false) val eventsRecorder = EventsRecorder<PreferencesRootEvent>(expectEvents = false)
ensureCalledOnce { callback -> ensureCalledOnce { callback ->
rule.setView( setView(
aPreferencesRootState( aPreferencesRootState(
nbOfBlockedUsers = 1, nbOfBlockedUsers = 1,
eventSink = eventsRecorder, eventSink = eventsRecorder,
), ),
onOpenBlockedUsers = callback, onOpenBlockedUsers = callback,
) )
rule.clickOn(CommonStrings.common_blocked_users) clickOn(CommonStrings.common_blocked_users)
} }
} }
@Test @Test
fun `when nbOfBlockedUsers is 0, item is not shown`() { fun `when nbOfBlockedUsers is 0, item is not shown`() = runAndroidComposeUiTest {
val eventsRecorder = EventsRecorder<PreferencesRootEvent>(expectEvents = false) val eventsRecorder = EventsRecorder<PreferencesRootEvent>(expectEvents = false)
rule.setView( setView(
aPreferencesRootState( aPreferencesRootState(
nbOfBlockedUsers = 0, nbOfBlockedUsers = 0,
eventSink = eventsRecorder, eventSink = eventsRecorder,
), ),
) )
rule.onNodeWithText(rule.activity.getString(CommonStrings.common_blocked_users)).assertDoesNotExist() onNodeWithText(activity!!.getString(CommonStrings.common_blocked_users)).assertDoesNotExist()
} }
@Test @Test
fun `click on Remove this device invokes the expected callback`() { fun `click on Remove this device invokes the expected callback`() = runAndroidComposeUiTest {
val eventsRecorder = EventsRecorder<PreferencesRootEvent>(expectEvents = false) val eventsRecorder = EventsRecorder<PreferencesRootEvent>(expectEvents = false)
ensureCalledOnce { callback -> ensureCalledOnce { callback ->
rule.setView( setView(
aPreferencesRootState( aPreferencesRootState(
eventSink = eventsRecorder, eventSink = eventsRecorder,
), ),
onSignOutClick = callback, onSignOutClick = callback,
) )
rule.clickOn(CommonStrings.action_signout) clickOn(CommonStrings.action_signout)
} }
} }
@Test @Test
fun `click on Deactivate invokes the expected callback`() { fun `click on Deactivate invokes the expected callback`() = runAndroidComposeUiTest {
val eventsRecorder = EventsRecorder<PreferencesRootEvent>(expectEvents = false) val eventsRecorder = EventsRecorder<PreferencesRootEvent>(expectEvents = false)
ensureCalledOnce { callback -> ensureCalledOnce { callback ->
rule.setView( setView(
aPreferencesRootState( aPreferencesRootState(
canDeactivateAccount = true, canDeactivateAccount = true,
eventSink = eventsRecorder, eventSink = eventsRecorder,
), ),
onDeactivateClick = callback, onDeactivateClick = callback,
) )
rule.clickOn(CommonStrings.action_delete_account) clickOn(CommonStrings.action_delete_account)
} }
} }
@Test @Test
fun `when canDeactivateAccount is false, item is not shown`() { fun `when canDeactivateAccount is false, item is not shown`() = runAndroidComposeUiTest {
val eventsRecorder = EventsRecorder<PreferencesRootEvent>(expectEvents = false) val eventsRecorder = EventsRecorder<PreferencesRootEvent>(expectEvents = false)
rule.setView( setView(
aPreferencesRootState( aPreferencesRootState(
canDeactivateAccount = false, canDeactivateAccount = false,
eventSink = eventsRecorder, eventSink = eventsRecorder,
), ),
) )
rule.onNodeWithText(rule.activity.getString(CommonStrings.action_delete_account)).assertDoesNotExist() onNodeWithText(activity!!.getString(CommonStrings.action_delete_account)).assertDoesNotExist()
} }
@Test @Test
fun `clicking on version sends a PreferencesRootEvents`() { fun `clicking on version sends a PreferencesRootEvents`() = runAndroidComposeUiTest {
val version = "VERSION" val version = "VERSION"
val eventsRecorder = EventsRecorder<PreferencesRootEvent>() val eventsRecorder = EventsRecorder<PreferencesRootEvent>()
rule.setView( setView(
aPreferencesRootState( aPreferencesRootState(
version = version, version = version,
eventSink = eventsRecorder, eventSink = eventsRecorder,
), ),
) )
rule.onNodeWithText(version).performClick() onNodeWithText(version).performClick()
eventsRecorder.assertSingle(PreferencesRootEvent.OnVersionInfoClick) eventsRecorder.assertSingle(PreferencesRootEvent.OnVersionInfoClick)
} }
} }
private fun <R : TestRule> AndroidComposeTestRule<R, ComponentActivity>.setView( private fun AndroidComposeUiTest<ComponentActivity>.setView(
state: PreferencesRootState, state: PreferencesRootState,
onBackClick: () -> Unit = EnsureNeverCalled(), onBackClick: () -> Unit = EnsureNeverCalled(),
onAddAccountClick: () -> Unit = EnsureNeverCalled(), onAddAccountClick: () -> Unit = EnsureNeverCalled(),

View file

@ -6,14 +6,17 @@
* Please see LICENSE files in the repository root for full details. * Please see LICENSE files in the repository root for full details.
*/ */
@file:OptIn(ExperimentalTestApi::class)
package io.element.android.features.preferences.impl.user.editprofile package io.element.android.features.preferences.impl.user.editprofile
import androidx.activity.ComponentActivity import androidx.activity.ComponentActivity
import androidx.compose.ui.test.junit4.AndroidComposeTestRule import androidx.compose.ui.test.AndroidComposeUiTest
import androidx.compose.ui.test.junit4.createAndroidComposeRule import androidx.compose.ui.test.ExperimentalTestApi
import androidx.compose.ui.test.onNodeWithContentDescription import androidx.compose.ui.test.onNodeWithContentDescription
import androidx.compose.ui.test.onNodeWithText import androidx.compose.ui.test.onNodeWithText
import androidx.compose.ui.test.performClick import androidx.compose.ui.test.performClick
import androidx.compose.ui.test.v2.runAndroidComposeUiTest
import androidx.test.ext.junit.runners.AndroidJUnit4 import androidx.test.ext.junit.runners.AndroidJUnit4
import io.element.android.libraries.architecture.AsyncAction import io.element.android.libraries.architecture.AsyncAction
import io.element.android.libraries.matrix.ui.media.AvatarAction import io.element.android.libraries.matrix.ui.media.AvatarAction
@ -23,96 +26,93 @@ import io.element.android.tests.testutils.EventsRecorder
import io.element.android.tests.testutils.clickOn import io.element.android.tests.testutils.clickOn
import io.element.android.tests.testutils.ensureCalledOnce import io.element.android.tests.testutils.ensureCalledOnce
import io.element.android.tests.testutils.pressBack import io.element.android.tests.testutils.pressBack
import org.junit.Rule
import org.junit.Test import org.junit.Test
import org.junit.rules.TestRule
import org.junit.runner.RunWith import org.junit.runner.RunWith
@RunWith(AndroidJUnit4::class) @RunWith(AndroidJUnit4::class)
class EditUserProfileViewTest { class EditUserProfileViewTest {
@get:Rule val rule = createAndroidComposeRule<ComponentActivity>()
@Test @Test
fun `clicking on back emits the expected event`() { fun `clicking on back emits the expected event`() = runAndroidComposeUiTest {
val eventsRecorder = EventsRecorder<EditUserProfileEvent>() val eventsRecorder = EventsRecorder<EditUserProfileEvent>()
rule.setEditUserProfileView( setEditUserProfileView(
aEditUserProfileState( aEditUserProfileState(
eventSink = eventsRecorder, eventSink = eventsRecorder,
), ),
) )
rule.pressBack() pressBack()
eventsRecorder.assertSingle(EditUserProfileEvent.Exit) eventsRecorder.assertSingle(EditUserProfileEvent.Exit)
} }
@Test @Test
fun `clicking on save from the exit confirmation dialog emits the expected event`() { fun `clicking on save from the exit confirmation dialog emits the expected event`() = runAndroidComposeUiTest {
val eventsRecorder = EventsRecorder<EditUserProfileEvent>() val eventsRecorder = EventsRecorder<EditUserProfileEvent>()
rule.setEditUserProfileView( setEditUserProfileView(
aEditUserProfileState( aEditUserProfileState(
saveAction = AsyncAction.ConfirmingCancellation, saveAction = AsyncAction.ConfirmingCancellation,
eventSink = eventsRecorder, eventSink = eventsRecorder,
), ),
) )
rule.clickOn(CommonStrings.action_save, inDialog = true) clickOn(CommonStrings.action_save, inDialog = true)
eventsRecorder.assertSingle(EditUserProfileEvent.Save) eventsRecorder.assertSingle(EditUserProfileEvent.Save)
} }
@Test @Test
fun `clicking on discard exit emits the expected event`() { fun `clicking on discard exit emits the expected event`() = runAndroidComposeUiTest {
val eventsRecorder = EventsRecorder<EditUserProfileEvent>() val eventsRecorder = EventsRecorder<EditUserProfileEvent>()
rule.setEditUserProfileView( setEditUserProfileView(
aEditUserProfileState( aEditUserProfileState(
saveAction = AsyncAction.ConfirmingCancellation, saveAction = AsyncAction.ConfirmingCancellation,
eventSink = eventsRecorder, eventSink = eventsRecorder,
), ),
) )
rule.clickOn(CommonStrings.action_discard) clickOn(CommonStrings.action_discard)
eventsRecorder.assertSingle(EditUserProfileEvent.Exit) eventsRecorder.assertSingle(EditUserProfileEvent.Exit)
} }
@Test @Test
fun `clicking on save emits the expected event`() { fun `clicking on save emits the expected event`() = runAndroidComposeUiTest {
val eventsRecorder = EventsRecorder<EditUserProfileEvent>() val eventsRecorder = EventsRecorder<EditUserProfileEvent>()
rule.setEditUserProfileView( setEditUserProfileView(
aEditUserProfileState( aEditUserProfileState(
saveButtonEnabled = true, saveButtonEnabled = true,
saveAction = AsyncAction.Uninitialized, saveAction = AsyncAction.Uninitialized,
eventSink = eventsRecorder, eventSink = eventsRecorder,
), ),
) )
rule.clickOn(CommonStrings.action_save) clickOn(CommonStrings.action_save)
eventsRecorder.assertSingle(EditUserProfileEvent.Save) eventsRecorder.assertSingle(EditUserProfileEvent.Save)
} }
@Test @Test
fun `clicking on avatar opens the bottom sheet dialog`() { fun `clicking on avatar opens the bottom sheet dialog`() = runAndroidComposeUiTest {
val eventsRecorder = EventsRecorder<EditUserProfileEvent>() val eventsRecorder = EventsRecorder<EditUserProfileEvent>()
val actions = listOf( val actions = listOf(
AvatarAction.TakePhoto, AvatarAction.TakePhoto,
AvatarAction.ChoosePhoto, AvatarAction.ChoosePhoto,
AvatarAction.Remove, AvatarAction.Remove,
) )
rule.setEditUserProfileView( setEditUserProfileView(
aEditUserProfileState( aEditUserProfileState(
saveAction = AsyncAction.Uninitialized, saveAction = AsyncAction.Uninitialized,
avatarActions = actions, avatarActions = actions,
eventSink = eventsRecorder, eventSink = eventsRecorder,
), ),
) )
val contentDescription = rule.activity.getString(CommonStrings.a11y_avatar) val resources = activity!!.resources
rule.onNodeWithContentDescription(contentDescription).performClick() val contentDescription = resources.getString(CommonStrings.a11y_avatar)
onNodeWithContentDescription(contentDescription).performClick()
// Assert that the actions are displayed // Assert that the actions are displayed
actions.forEach { action -> actions.forEach { action ->
val text = rule.activity.getString(action.titleResId) val text = resources.getString(action.titleResId)
rule.onNodeWithText(text).assertExists() onNodeWithText(text).assertExists()
} }
} }
@Test @Test
fun `success invokes the expected callback`() { fun `success invokes the expected callback`() = runAndroidComposeUiTest {
val eventsRecorder = EventsRecorder<EditUserProfileEvent>(expectEvents = false) val eventsRecorder = EventsRecorder<EditUserProfileEvent>(expectEvents = false)
ensureCalledOnce { callback -> ensureCalledOnce { callback ->
rule.setEditUserProfileView( setEditUserProfileView(
aEditUserProfileState( aEditUserProfileState(
saveAction = AsyncAction.Success(Unit), saveAction = AsyncAction.Success(Unit),
eventSink = eventsRecorder, eventSink = eventsRecorder,
@ -123,7 +123,7 @@ class EditUserProfileViewTest {
} }
} }
private fun <R : TestRule> AndroidComposeTestRule<R, ComponentActivity>.setEditUserProfileView( private fun AndroidComposeUiTest<ComponentActivity>.setEditUserProfileView(
state: EditUserProfileState, state: EditUserProfileState,
onEditProfileSuccess: () -> Unit = EnsureNeverCalled(), onEditProfileSuccess: () -> Unit = EnsureNeverCalled(),
) { ) {

Some files were not shown because too many files have changed in this diff Show more