Merge pull request #2167 from vector-im/feature/ui_test

Feature/ui test
This commit is contained in:
Benoit Marty 2020-09-29 16:13:54 +02:00 committed by GitHub
commit 7a494db40b
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
15 changed files with 1231 additions and 10 deletions

View file

@ -21,6 +21,7 @@ Build 🧱:
-
Other changes:
- Added registration/verification automated UI tests
- Create a script to help getting public information form any homeserver
Changes in Element 1.0.8 (2020-09-25)

107
docs/ui-tests.md Normal file
View file

@ -0,0 +1,107 @@
# Automate user interface tests
Element Android ensures that some fundamental flows are properly working by running automated user interface tests.
Ui tests are using the android [Espresso](https://developer.android.com/training/testing/espresso) library.
Tests can be run on a real device, or on a virtual device (such as the emulator in Android Studio).
Currently the test are covering a small set of application flows:
- Registration
- Self verification via emoji
- Self verification via passphrase
## Prerequisites:
Out of the box, the tests use one of the homeservers (located at http://localhost:8080) of the "Demo Federation of Homeservers" (https://github.com/matrix-org/synapse#running-a-demo-federation-of-synapses).
You first need to follow instructions to set up Synapse in development mode at https://github.com/matrix-org/synapse#synapse-development. If you have already installed all dependencies, the steps are:
```shell script
$ git clone https://github.com/matrix-org/synapse.git
$ cd synapse
$ virtualenv -p python3 env
$ source env/bin/activate
(env) $ python -m pip install --no-use-pep517 -e .
```
Every time you want to launch these test homeservers, type:
```shell script
$ virtualenv -p python3 env
$ source env/bin/activate
(env) $ demo/start.sh --no-rate-limit
```
**Emulator/Device set up**
When running the test via android studio on a device, you have to disable system animations in order for the test to work properly.
First, ensure developer mode is enabled:
- To enable developer options, tap the **Build Number** option 7 times. You can find this option in one of the following locations, depending on your Android version:
- Android 9 (API level 28) and higher: **Settings > About Phone > Build Number**
- Android 8.0.0 (API level 26) and Android 8.1.0 (API level 26): **Settings > System > About Phone > Build Number**
- Android 7.1 (API level 25) and lower: **Settings > About Phone > Build Number**
On your device, under **Settings > Developer options**, disable the following 3 settings:
- Window animation scale
- Transition animation scale
- Animator duration scale
## Run the tests
Once Synapse is running, and an emulator is running, you can run the UI tests.
### From the source code
Click on the green arrow in front of each test. Clicking on the arrow in front of the test class, or from the package directory does not always work (Tests not found issue).
### From command line
````shell script
./gradlew vector:connectedGplayDebugAndroidTest
````
To run all the tests from the `vector` module.
In case of trouble, you can try to uninstall the previous installed test APK first with this command:
```shell script
adb uninstall im.vector.app.debug.test
```
## Recipes
We added some specific Espresso IdlingResources, and other utilities for matrix related tests
### Wait for initial sync
```kotlin
// Wait for initial sync and check room list is there
withIdlingResource(initialSyncIdlingResource(uiSession)) {
onView(withId(R.id.roomListContainer))
.check(matches(isDisplayed()))
}
```
### Accessing current activity
```kotlin
val activity = EspressoHelper.getCurrentActivity()!!
val uiSession = (activity as HomeActivity).activeSessionHolder.getActiveSession()
```
### Interact with other session
It's possible to create a session via the SDK, and then use this session to interact with the one that the emulator is using (to check verifications for example)
```kotlin
@Before
fun initAccount() {
val context = InstrumentationRegistry.getInstrumentation().targetContext
val matrix = Matrix.getInstance(context)
val userName = "foobar_${System.currentTimeMillis()}"
existingSession = createAccountAndSync(matrix, userName, password, true)
}
```

View file

@ -218,7 +218,7 @@ class CommonTestHelper(context: Context) {
.createAccount(userName, password, null, it)
}
// Preform dummy step
// Perform dummy step
val registrationResult = doSync<RegistrationResult> {
matrix.authenticationService
.getRegistrationWizard()

View file

@ -126,7 +126,7 @@ internal class OutgoingGossipingRequestManager @Inject constructor(
* @param request the request
*/
private fun sendOutgoingGossipingRequest(request: OutgoingGossipingRequest) {
Timber.v("## CRYPTO - GOSSIP sendOutgoingRoomKeyRequest() : Requesting keys $request")
Timber.v("## CRYPTO - GOSSIP sendOutgoingGossipingRequest() : Requesting keys $request")
val params = SendGossipRequestWorker.Params(
sessionId = sessionId,

View file

@ -372,6 +372,7 @@ internal class RealmCryptoStore @Inject constructor(
}
override fun storePrivateKeysInfo(msk: String?, usk: String?, ssk: String?) {
Timber.v("## CRYPTO | *** storePrivateKeysInfo ${msk != null}, ${usk != null}, ${ssk != null}")
doRealmTransaction(realmConfiguration) { realm ->
realm.where<CryptoMetadataEntity>().findFirst()?.apply {
xSignMasterPrivateKey = msk
@ -407,6 +408,7 @@ internal class RealmCryptoStore @Inject constructor(
}
override fun storeMSKPrivateKey(msk: String?) {
Timber.v("## CRYPTO | *** storeMSKPrivateKey ${msk != null} ")
doRealmTransaction(realmConfiguration) { realm ->
realm.where<CryptoMetadataEntity>().findFirst()?.apply {
xSignMasterPrivateKey = msk
@ -415,6 +417,7 @@ internal class RealmCryptoStore @Inject constructor(
}
override fun storeSSKPrivateKey(ssk: String?) {
Timber.v("## CRYPTO | *** storeSSKPrivateKey ${ssk != null} ")
doRealmTransaction(realmConfiguration) { realm ->
realm.where<CryptoMetadataEntity>().findFirst()?.apply {
xSignSelfSignedPrivateKey = ssk
@ -423,6 +426,7 @@ internal class RealmCryptoStore @Inject constructor(
}
override fun storeUSKPrivateKey(usk: String?) {
Timber.v("## CRYPTO | *** storeUSKPrivateKey ${usk != null} ")
doRealmTransaction(realmConfiguration) { realm ->
realm.where<CryptoMetadataEntity>().findFirst()?.apply {
xSignUserPrivateKey = usk

View file

@ -172,6 +172,19 @@ android {
output.versionCodeOverride = variant.versionCode * 10 + baseAbiVersionCode
}
}
// The following argument makes the Android Test Orchestrator run its
// "pm clear" command after each test invocation. This command ensures
// that the app's state is completely cleared between tests.
testInstrumentationRunnerArguments clearPackageData: 'true'
}
testOptions {
// Disables animations during instrumented tests you run from the command line
// This property does not affect tests that you run using Android Studio.
animationsDisabled = true
execution 'ANDROIDX_TEST_ORCHESTRATOR'
}
signingConfigs {
@ -281,6 +294,11 @@ dependencies {
def arch_version = '2.1.0'
def lifecycle_version = '2.2.0'
// Tests
def kluent_version = '1.44'
def androidxTest_version = '1.3.0'
def espresso_version = '3.3.0'
implementation project(":matrix-sdk-android")
implementation project(":matrix-sdk-android-rx")
implementation project(":diff-match-patch")
@ -422,19 +440,20 @@ dependencies {
// TESTS
testImplementation 'junit:junit:4.12'
testImplementation 'org.amshove.kluent:kluent-android:1.44'
testImplementation "org.amshove.kluent:kluent-android:$kluent_version"
// Plant Timber tree for test
testImplementation 'net.lachlanmckee:timber-junit-rule:1.0.1'
// Activate when you want to check for leaks, from time to time.
//debugImplementation 'com.squareup.leakcanary:leakcanary-android:2.3'
androidTestImplementation 'androidx.test:core:1.2.0'
androidTestImplementation 'androidx.test:runner:1.2.0'
androidTestImplementation 'androidx.test:rules:1.2.0'
androidTestImplementation 'androidx.test.ext:junit:1.1.1'
androidTestImplementation 'androidx.test.espresso:espresso-core:3.2.0'
androidTestImplementation 'org.amshove.kluent:kluent-android:1.44'
androidTestImplementation "androidx.test:core:$androidxTest_version"
androidTestImplementation "androidx.test:runner:$androidxTest_version"
androidTestImplementation "androidx.test:rules:$androidxTest_version"
androidTestImplementation 'androidx.test.ext:junit:1.1.2'
androidTestImplementation "androidx.test.espresso:espresso-core:$espresso_version"
androidTestImplementation "androidx.test.espresso:espresso-contrib:$espresso_version"
androidTestImplementation "org.amshove.kluent:kluent-android:$kluent_version"
androidTestImplementation "androidx.arch.core:core-testing:$arch_version"
// Plant Timber tree for test
androidTestImplementation 'net.lachlanmckee:timber-junit-rule:1.0.1'

View file

@ -0,0 +1,207 @@
/*
* Copyright (c) 2020 New Vector Ltd
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package im.vector.app
import android.app.Activity
import android.view.View
import androidx.lifecycle.Observer
import androidx.test.espresso.IdlingRegistry
import androidx.test.espresso.IdlingResource
import androidx.test.espresso.PerformException
import androidx.test.espresso.UiController
import androidx.test.espresso.ViewAction
import androidx.test.espresso.matcher.ViewMatchers.isDisplayed
import androidx.test.espresso.util.HumanReadables
import androidx.test.espresso.util.TreeIterables
import androidx.test.internal.runner.junit4.statement.UiThreadStatement.runOnUiThread
import androidx.test.platform.app.InstrumentationRegistry.getInstrumentation
import androidx.test.runner.lifecycle.ActivityLifecycleCallback
import androidx.test.runner.lifecycle.ActivityLifecycleMonitorRegistry
import androidx.test.runner.lifecycle.Stage
import org.hamcrest.Matcher
import org.hamcrest.Matchers
import org.hamcrest.StringDescription
import org.matrix.android.sdk.api.session.Session
import org.matrix.android.sdk.api.session.sync.SyncState
import org.matrix.android.sdk.api.util.Optional
import org.matrix.android.sdk.internal.crypto.store.PrivateKeysInfo
import java.util.concurrent.TimeoutException
object EspressoHelper {
fun getCurrentActivity(): Activity? {
var currentActivity: Activity? = null
getInstrumentation().runOnMainSync {
currentActivity = ActivityLifecycleMonitorRegistry.getInstance().getActivitiesInStage(Stage.RESUMED).elementAtOrNull(0)
}
return currentActivity
}
}
fun waitForView(viewMatcher: Matcher<View>, timeout: Long = 10_000, waitForDisplayed: Boolean = true): ViewAction {
return object : ViewAction {
override fun getConstraints(): Matcher<View> {
return Matchers.any(View::class.java)
}
override fun getDescription(): String {
val matcherDescription = StringDescription()
viewMatcher.describeTo(matcherDescription)
return "wait for a specific view <$matcherDescription> to be ${if (waitForDisplayed) "displayed" else "not displayed during $timeout millis."}"
}
override fun perform(uiController: UiController, view: View) {
println("*** waitForView 1 $view")
uiController.loopMainThreadUntilIdle()
val startTime = System.currentTimeMillis()
val endTime = startTime + timeout
val visibleMatcher = isDisplayed()
do {
println("*** waitForView loop $view end:$endTime current:${System.currentTimeMillis()}")
val viewVisible = TreeIterables.breadthFirstViewTraversal(view)
.any { viewMatcher.matches(it) && visibleMatcher.matches(it) }
println("*** waitForView loop viewVisible:$viewVisible")
if (viewVisible == waitForDisplayed) return
println("*** waitForView loop loopMainThreadForAtLeast...")
uiController.loopMainThreadForAtLeast(50)
println("*** waitForView loop ...loopMainThreadForAtLeast")
} while (System.currentTimeMillis() < endTime)
println("*** waitForView timeout $view")
// Timeout happens.
throw PerformException.Builder()
.withActionDescription(this.description)
.withViewDescription(HumanReadables.describe(view))
.withCause(TimeoutException())
.build()
}
}
}
fun initialSyncIdlingResource(session: Session): IdlingResource {
val res = object : IdlingResource, Observer<SyncState> {
private var callback: IdlingResource.ResourceCallback? = null
override fun getName() = "InitialSyncIdlingResource for ${session.myUserId}"
override fun isIdleNow(): Boolean {
val isIdle = session.hasAlreadySynced()
return isIdle
}
override fun registerIdleTransitionCallback(callback: IdlingResource.ResourceCallback?) {
this.callback = callback
}
override fun onChanged(t: SyncState?) {
val isIdle = session.hasAlreadySynced()
if (isIdle) {
callback?.onTransitionToIdle()
session.getSyncStateLive().removeObserver(this)
}
}
}
runOnUiThread {
session.getSyncStateLive().observeForever(res)
}
return res
}
fun activityIdlingResource(activityClass: Class<*>): IdlingResource {
val res = object : IdlingResource, ActivityLifecycleCallback {
private var callback: IdlingResource.ResourceCallback? = null
var hasResumed = false
private var currentActivity : Activity? = null
val uniqTS = System.currentTimeMillis()
override fun getName() = "activityIdlingResource_${activityClass.name}_$uniqTS"
override fun isIdleNow(): Boolean {
val currentActivity = currentActivity ?: ActivityLifecycleMonitorRegistry.getInstance().getActivitiesInStage(Stage.RESUMED).elementAtOrNull(0)
val isIdle = hasResumed || currentActivity?.javaClass?.let { activityClass.isAssignableFrom(it) } ?: false
println("*** [$name] isIdleNow activityIdlingResource $currentActivity isIdle:$isIdle")
return isIdle
}
override fun registerIdleTransitionCallback(callback: IdlingResource.ResourceCallback?) {
println("*** [$name] registerIdleTransitionCallback $callback")
this.callback = callback
// if (hasResumed) callback?.onTransitionToIdle()
}
override fun onActivityLifecycleChanged(activity: Activity?, stage: Stage?) {
println("*** [$name] onActivityLifecycleChanged $activity $stage")
currentActivity = ActivityLifecycleMonitorRegistry.getInstance().getActivitiesInStage(Stage.RESUMED).elementAtOrNull(0)
val isIdle = currentActivity?.javaClass?.let { activityClass.isAssignableFrom(it) } ?: false
println("*** [$name] onActivityLifecycleChanged $currentActivity isIdle:$isIdle")
if (isIdle) {
hasResumed = true
println("*** [$name] onActivityLifecycleChanged callback: $callback")
callback?.onTransitionToIdle()
ActivityLifecycleMonitorRegistry.getInstance().removeLifecycleCallback(this)
}
}
}
ActivityLifecycleMonitorRegistry.getInstance().addLifecycleCallback(res)
return res
}
fun withIdlingResource(idlingResource: IdlingResource, block: (() -> Unit)) {
println("*** withIdlingResource register")
IdlingRegistry.getInstance().register(idlingResource)
block.invoke()
println("*** withIdlingResource unregister")
IdlingRegistry.getInstance().unregister(idlingResource)
}
fun allSecretsKnownIdling(session: Session): IdlingResource {
val res = object : IdlingResource, Observer<Optional<PrivateKeysInfo>> {
private var callback: IdlingResource.ResourceCallback? = null
var privateKeysInfo: PrivateKeysInfo? = session.cryptoService().crossSigningService().getCrossSigningPrivateKeys()
override fun getName() = "AllSecretsKnownIdling_${session.myUserId}"
override fun isIdleNow(): Boolean {
println("*** [$name]/isIdleNow allSecretsKnownIdling ${privateKeysInfo?.allKnown()}")
return privateKeysInfo?.allKnown() == true
}
override fun registerIdleTransitionCallback(callback: IdlingResource.ResourceCallback?) {
this.callback = callback
}
override fun onChanged(t: Optional<PrivateKeysInfo>?) {
println("*** [$name] allSecretsKnownIdling ${t?.getOrNull()}")
privateKeysInfo = t?.getOrNull()
if (t?.getOrNull()?.allKnown() == true) {
session.cryptoService().crossSigningService().getLiveCrossSigningPrivateKeys().removeObserver(this)
callback?.onTransitionToIdle()
}
}
}
runOnUiThread {
session.cryptoService().crossSigningService().getLiveCrossSigningPrivateKeys().observeForever(res)
}
return res
}

View file

@ -0,0 +1,120 @@
/*
* Copyright (c) 2020 New Vector Ltd
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package im.vector.app
import androidx.test.espresso.Espresso.onView
import androidx.test.espresso.action.ViewActions.click
import androidx.test.espresso.action.ViewActions.closeSoftKeyboard
import androidx.test.espresso.action.ViewActions.typeText
import androidx.test.espresso.assertion.ViewAssertions.matches
import androidx.test.espresso.matcher.ViewMatchers.isDisplayed
import androidx.test.espresso.matcher.ViewMatchers.isEnabled
import androidx.test.espresso.matcher.ViewMatchers.withId
import androidx.test.espresso.matcher.ViewMatchers.withText
import androidx.test.ext.junit.rules.ActivityScenarioRule
import androidx.test.ext.junit.runners.AndroidJUnit4
import androidx.test.filters.LargeTest
import im.vector.app.features.MainActivity
import im.vector.app.features.home.HomeActivity
import org.hamcrest.CoreMatchers.not
import org.junit.Rule
import org.junit.Test
import org.junit.runner.RunWith
@RunWith(AndroidJUnit4::class)
@LargeTest
class RegistrationTest {
@get:Rule
val activityRule = ActivityScenarioRule(MainActivity::class.java)
@Test
fun simpleRegister() {
val userId: String = "UiAutoTest_${System.currentTimeMillis()}"
val password: String = "password"
val homeServerUrl: String = "http://10.0.2.2:8080"
// Check splashscreen is there
onView(withId(R.id.loginSplashSubmit))
.check(matches(isDisplayed()))
.check(matches(withText(R.string.login_splash_submit)))
// Click on get started
onView(withId(R.id.loginSplashSubmit))
.perform(click())
// Check that home server options are shown
onView(withId(R.id.loginServerTitle))
.check(matches(isDisplayed()))
.check(matches(withText(R.string.login_server_title)))
// Chose custom server
onView(withId(R.id.loginServerChoiceOther))
.perform(click())
// Enter local synapse
onView((withId(R.id.loginServerUrlFormHomeServerUrl)))
.perform(typeText(homeServerUrl))
// Click on continue
onView(withId(R.id.loginServerUrlFormSubmit))
.check(matches(isEnabled()))
.perform(closeSoftKeyboard(), click())
// Click on the signup button
onView(withId(R.id.loginSignupSigninSubmit))
.check(matches(isDisplayed()))
.perform(click())
// Ensure password flow supported
onView(withId(R.id.loginField))
.check(matches(isDisplayed()))
onView(withId(R.id.passwordField))
.check(matches(isDisplayed()))
// Ensure user id
onView((withId(R.id.loginField)))
.perform(typeText(userId))
// Ensure login button not yet enabled
onView(withId(R.id.loginSubmit))
.check(matches(not(isEnabled())))
// Ensure password
onView((withId(R.id.passwordField)))
.perform(closeSoftKeyboard(), typeText(password))
// Submit
onView(withId(R.id.loginSubmit))
.check(matches(isEnabled()))
.perform(closeSoftKeyboard(), click())
withIdlingResource(activityIdlingResource(HomeActivity::class.java)) {
onView(withId(R.id.roomListContainer))
.check(matches(isDisplayed()))
}
val activity = EspressoHelper.getCurrentActivity()!!
val uiSession = (activity as HomeActivity).activeSessionHolder.getActiveSession()
// Wait for initial sync and check room list is there
withIdlingResource(initialSyncIdlingResource(uiSession)) {
onView(withId(R.id.roomListContainer))
.check(matches(isDisplayed()))
}
}
}

View file

@ -0,0 +1,49 @@
/*
* Copyright (c) 2020 New Vector Ltd
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package im.vector.app;
import android.view.View;
import androidx.test.espresso.UiController;
import androidx.test.espresso.ViewAction;
import org.hamcrest.Matcher;
import static androidx.test.espresso.matcher.ViewMatchers.isRoot;
public class SleepViewAction {
public static ViewAction sleep(final long millis) {
return new ViewAction() {
@Override
public Matcher<View> getConstraints() {
return isRoot();
}
@Override
public String getDescription() {
return "Wait for at least " + millis + " millis";
}
@Override
public void perform(final UiController uiController, final View view) {
uiController.loopMainThreadUntilIdle();
uiController.loopMainThreadForAtLeast(millis);
}
};
}
}

View file

@ -0,0 +1,48 @@
/*
* Copyright (c) 2020 New Vector Ltd
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package im.vector.app
import androidx.annotation.CallSuper
import junit.framework.TestCase.fail
import org.matrix.android.sdk.api.MatrixCallback
import timber.log.Timber
import java.util.concurrent.CountDownLatch
/**
* Simple implementation of MatrixCallback, which count down the CountDownLatch on each API callback
* @param onlySuccessful true to fail if an error occurs. This is the default behavior
* @param <T>
*/
open class TestMatrixCallback<T>(private val countDownLatch: CountDownLatch,
private val onlySuccessful: Boolean = true) : MatrixCallback<T> {
@CallSuper
override fun onSuccess(data: T) {
countDownLatch.countDown()
}
@CallSuper
override fun onFailure(failure: Throwable) {
Timber.e(failure, "TestApiCallback")
if (onlySuccessful) {
fail("onFailure " + failure.localizedMessage)
}
countDownLatch.countDown()
}
}

View file

@ -0,0 +1,225 @@
/*
* Copyright (c) 2020 New Vector Ltd
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package im.vector.app
import android.net.Uri
import androidx.lifecycle.Observer
import androidx.test.espresso.Espresso
import androidx.test.espresso.action.ViewActions
import androidx.test.espresso.assertion.ViewAssertions
import androidx.test.espresso.matcher.ViewMatchers
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.GlobalScope
import kotlinx.coroutines.launch
import kotlinx.coroutines.runBlocking
import org.hamcrest.CoreMatchers
import org.junit.Assert
import org.matrix.android.sdk.api.Matrix
import org.matrix.android.sdk.api.MatrixCallback
import org.matrix.android.sdk.api.auth.data.HomeServerConnectionConfig
import org.matrix.android.sdk.api.auth.data.LoginFlowResult
import org.matrix.android.sdk.api.auth.registration.RegistrationResult
import org.matrix.android.sdk.api.session.Session
import org.matrix.android.sdk.api.session.sync.SyncState
import java.util.concurrent.CountDownLatch
import java.util.concurrent.TimeUnit
abstract class VerificationTestBase {
val password = "password"
val homeServerUrl: String = "http://10.0.2.2:8080"
fun doLogin(homeServerUrl: String, userId: String, password: String) {
Espresso.onView(ViewMatchers.withId(R.id.loginSplashSubmit))
.check(ViewAssertions.matches(ViewMatchers.isDisplayed()))
.check(ViewAssertions.matches(ViewMatchers.withText(R.string.login_splash_submit)))
Espresso.onView(ViewMatchers.withId(R.id.loginSplashSubmit))
.perform(ViewActions.click())
Espresso.onView(ViewMatchers.withId(R.id.loginServerTitle))
.check(ViewAssertions.matches(ViewMatchers.isDisplayed()))
.check(ViewAssertions.matches(ViewMatchers.withText(R.string.login_server_title)))
// Chose custom server
Espresso.onView(ViewMatchers.withId(R.id.loginServerChoiceOther))
.perform(ViewActions.click())
// Enter local synapse
Espresso.onView((ViewMatchers.withId(R.id.loginServerUrlFormHomeServerUrl)))
.perform(ViewActions.typeText(homeServerUrl))
Espresso.onView(ViewMatchers.withId(R.id.loginServerUrlFormSubmit))
.check(ViewAssertions.matches(ViewMatchers.isEnabled()))
.perform(ViewActions.closeSoftKeyboard(), ViewActions.click())
// Click on the signin button
Espresso.onView(ViewMatchers.withId(R.id.loginSignupSigninSignIn))
.check(ViewAssertions.matches(ViewMatchers.isDisplayed()))
.perform(ViewActions.click())
// Ensure password flow supported
Espresso.onView(ViewMatchers.withId(R.id.loginField))
.check(ViewAssertions.matches(ViewMatchers.isDisplayed()))
Espresso.onView(ViewMatchers.withId(R.id.passwordField))
.check(ViewAssertions.matches(ViewMatchers.isDisplayed()))
Espresso.onView((ViewMatchers.withId(R.id.loginField)))
.perform(ViewActions.typeText(userId))
Espresso.onView(ViewMatchers.withId(R.id.loginSubmit))
.check(ViewAssertions.matches(CoreMatchers.not(ViewMatchers.isEnabled())))
Espresso.onView((ViewMatchers.withId(R.id.passwordField)))
.perform(ViewActions.closeSoftKeyboard(), ViewActions.typeText(password))
Espresso.onView(ViewMatchers.withId(R.id.loginSubmit))
.check(ViewAssertions.matches(ViewMatchers.isEnabled()))
.perform(ViewActions.closeSoftKeyboard(), ViewActions.click())
}
private fun createAccount(userId: String = "UiAutoTest", password: String = "password", homeServerUrl: String = "http://10.0.2.2:8080") {
Espresso.onView(ViewMatchers.withId(R.id.loginSplashSubmit))
.check(ViewAssertions.matches(ViewMatchers.isDisplayed()))
.check(ViewAssertions.matches(ViewMatchers.withText(R.string.login_splash_submit)))
Espresso.onView(ViewMatchers.withId(R.id.loginSplashSubmit))
.perform(ViewActions.click())
Espresso.onView(ViewMatchers.withId(R.id.loginServerTitle))
.check(ViewAssertions.matches(ViewMatchers.isDisplayed()))
.check(ViewAssertions.matches(ViewMatchers.withText(R.string.login_server_title)))
// Chose custom server
Espresso.onView(ViewMatchers.withId(R.id.loginServerChoiceOther))
.perform(ViewActions.click())
// Enter local synapse
Espresso.onView((ViewMatchers.withId(R.id.loginServerUrlFormHomeServerUrl)))
.perform(ViewActions.typeText(homeServerUrl))
Espresso.onView(ViewMatchers.withId(R.id.loginServerUrlFormSubmit))
.check(ViewAssertions.matches(ViewMatchers.isEnabled()))
.perform(ViewActions.closeSoftKeyboard(), ViewActions.click())
// Click on the signup button
Espresso.onView(ViewMatchers.withId(R.id.loginSignupSigninSubmit))
.check(ViewAssertions.matches(ViewMatchers.isDisplayed()))
.perform(ViewActions.click())
// Ensure password flow supported
Espresso.onView(ViewMatchers.withId(R.id.loginField))
.check(ViewAssertions.matches(ViewMatchers.isDisplayed()))
Espresso.onView(ViewMatchers.withId(R.id.passwordField))
.check(ViewAssertions.matches(ViewMatchers.isDisplayed()))
Espresso.onView((ViewMatchers.withId(R.id.loginField)))
.perform(ViewActions.typeText(userId))
Espresso.onView(ViewMatchers.withId(R.id.loginSubmit))
.check(ViewAssertions.matches(CoreMatchers.not(ViewMatchers.isEnabled())))
Espresso.onView((ViewMatchers.withId(R.id.passwordField)))
.perform(ViewActions.typeText(password))
Espresso.onView(ViewMatchers.withId(R.id.loginSubmit))
.check(ViewAssertions.matches(ViewMatchers.isEnabled()))
.perform(ViewActions.closeSoftKeyboard(), ViewActions.click())
Espresso.onView(ViewMatchers.withId(R.id.homeDrawerFragmentContainer))
.check(ViewAssertions.matches(ViewMatchers.isDisplayed()))
}
fun createAccountAndSync(matrix: Matrix, userName: String,
password: String,
withInitialSync: Boolean): Session {
val hs = createHomeServerConfig()
doSync<LoginFlowResult> {
matrix.authenticationService()
.getLoginFlow(hs, it)
}
doSync<RegistrationResult> {
matrix.authenticationService()
.getRegistrationWizard()
.createAccount(userName, password, null, it)
}
// Perform dummy step
val registrationResult = doSync<RegistrationResult> {
matrix.authenticationService()
.getRegistrationWizard()
.dummy(it)
}
Assert.assertTrue(registrationResult is RegistrationResult.Success)
val session = (registrationResult as RegistrationResult.Success).session
if (withInitialSync) {
syncSession(session)
}
return session
}
fun createHomeServerConfig(): HomeServerConnectionConfig {
return HomeServerConnectionConfig.Builder()
.withHomeServerUri(Uri.parse(homeServerUrl))
.build()
}
// Transform a method with a MatrixCallback to a synchronous method
inline fun <reified T> doSync(block: (MatrixCallback<T>) -> Unit): T {
val lock = CountDownLatch(1)
var result: T? = null
val callback = object : TestMatrixCallback<T>(lock) {
override fun onSuccess(data: T) {
result = data
super.onSuccess(data)
}
}
block.invoke(callback)
lock.await(20_000, TimeUnit.MILLISECONDS)
Assert.assertNotNull(result)
return result!!
}
fun syncSession(session: Session) {
val lock = CountDownLatch(1)
GlobalScope.launch(Dispatchers.Main) { session.open() }
session.startSync(true)
val syncLiveData = runBlocking(Dispatchers.Main) {
session.getSyncStateLive()
}
val syncObserver = object : Observer<SyncState> {
override fun onChanged(t: SyncState?) {
if (session.hasAlreadySynced()) {
lock.countDown()
syncLiveData.removeObserver(this)
}
}
}
GlobalScope.launch(Dispatchers.Main) { syncLiveData.observeForever(syncObserver) }
lock.await(20_000, TimeUnit.MILLISECONDS)
}
}

View file

@ -0,0 +1,272 @@
/*
* Copyright (c) 2020 New Vector Ltd
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package im.vector.app
import android.view.View
import androidx.recyclerview.widget.RecyclerView
import androidx.test.espresso.Espresso.onView
import androidx.test.espresso.IdlingRegistry
import androidx.test.espresso.IdlingResource
import androidx.test.espresso.action.ViewActions.click
import androidx.test.espresso.action.ViewActions.closeSoftKeyboard
import androidx.test.espresso.assertion.ViewAssertions.matches
import androidx.test.espresso.contrib.RecyclerViewActions.actionOnItem
import androidx.test.espresso.matcher.ViewMatchers.hasDescendant
import androidx.test.espresso.matcher.ViewMatchers.isDisplayed
import androidx.test.espresso.matcher.ViewMatchers.isRoot
import androidx.test.espresso.matcher.ViewMatchers.withId
import androidx.test.espresso.matcher.ViewMatchers.withText
import androidx.test.ext.junit.rules.ActivityScenarioRule
import androidx.test.ext.junit.runners.AndroidJUnit4
import androidx.test.filters.LargeTest
import androidx.test.platform.app.InstrumentationRegistry
import im.vector.app.features.MainActivity
import im.vector.app.features.home.HomeActivity
import org.hamcrest.CoreMatchers.not
import org.junit.Before
import org.junit.Rule
import org.junit.Test
import org.junit.runner.RunWith
import org.matrix.android.sdk.api.Matrix
import org.matrix.android.sdk.api.session.Session
import org.matrix.android.sdk.api.session.crypto.verification.SasVerificationTransaction
import org.matrix.android.sdk.api.session.crypto.verification.VerificationMethod
import org.matrix.android.sdk.api.session.crypto.verification.VerificationService
import org.matrix.android.sdk.api.session.crypto.verification.VerificationTransaction
import org.matrix.android.sdk.api.session.crypto.verification.VerificationTxState
import org.matrix.android.sdk.internal.crypto.model.rest.UserPasswordAuth
@RunWith(AndroidJUnit4::class)
@LargeTest
class VerifySessionInteractiveTest : VerificationTestBase() {
var existingSession: Session? = null
@get:Rule
val activityRule = ActivityScenarioRule(MainActivity::class.java)
@Before
fun createSessionWithCrossSigning() {
val context = InstrumentationRegistry.getInstrumentation().targetContext
val matrix = Matrix.getInstance(context)
val userName = "foobar_${System.currentTimeMillis()}"
existingSession = createAccountAndSync(matrix, userName, password, true)
doSync<Unit> {
existingSession!!.cryptoService().crossSigningService()
.initializeCrossSigning(UserPasswordAuth(
user = existingSession!!.myUserId,
password = "password"
), it)
}
}
@Test
fun checkVerifyPopup() {
val userId: String = existingSession!!.myUserId
doLogin(homeServerUrl, userId, password)
// Thread.sleep(6000)
withIdlingResource(activityIdlingResource(HomeActivity::class.java)) {
onView(withId(R.id.roomListContainer))
.check(matches(isDisplayed()))
.perform(closeSoftKeyboard())
}
val activity = EspressoHelper.getCurrentActivity()!!
val uiSession = (activity as HomeActivity).activeSessionHolder.getActiveSession()
withIdlingResource(initialSyncIdlingResource(uiSession)) {
onView(withId(R.id.roomListContainer))
.check(matches(isDisplayed()))
}
// THIS IS THE ONLY WAY I FOUND TO CLICK ON ALERTERS... :(
// Cannot wait for view because of alerter animation? ...
onView(isRoot())
.perform(waitForView(withId(com.tapadoo.alerter.R.id.llAlertBackground)))
// Thread.sleep(1000)
// onView(withId(com.tapadoo.alerter.R.id.llAlertBackground))
// .perform(click())
Thread.sleep(1000)
val popup = activity.findViewById<View>(com.tapadoo.alerter.R.id.llAlertBackground)
activity.runOnUiThread {
popup.performClick()
}
onView(isRoot())
.perform(waitForView(withId(R.id.bottomSheetFragmentContainer)))
// .check()
// onView(withId(R.id.bottomSheetFragmentContainer))
// .check(matches(isDisplayed()))
// onView(isRoot()).perform(SleepViewAction.sleep(2000))
onView(withText(R.string.use_latest_app))
.check(matches(isDisplayed()))
// 4S is not setup so passphrase option should be hidden
onView(withId(R.id.bottomSheetFragmentContainer))
.check(matches(not(hasDescendant(withText(R.string.verification_cannot_access_other_session)))))
val request = existingSession!!.cryptoService().verificationService().requestKeyVerification(
listOf(VerificationMethod.SAS, VerificationMethod.QR_CODE_SCAN, VerificationMethod.QR_CODE_SHOW),
existingSession!!.myUserId,
listOf(uiSession.sessionParams.deviceId!!)
)
val transactionId = request.transactionId!!
val sasReadyIdle = verificationStateIdleResource(transactionId, VerificationTxState.ShortCodeReady, uiSession)
val otherSessionSasReadyIdle = verificationStateIdleResource(transactionId, VerificationTxState.ShortCodeReady, existingSession!!)
onView(isRoot()).perform(SleepViewAction.sleep(1000))
// Assert QR code option is there and available
onView(withId(R.id.bottomSheetVerificationRecyclerView))
.check(matches(hasDescendant(withText(R.string.verification_scan_their_code))))
onView(withId(R.id.bottomSheetVerificationRecyclerView))
.check(matches(hasDescendant(withId(R.id.itemVerificationQrCodeImage))))
onView(withId(R.id.bottomSheetVerificationRecyclerView))
.perform(
actionOnItem<RecyclerView.ViewHolder>(
hasDescendant(withText(R.string.verification_scan_emoji_title)),
click()
)
)
val firstSessionTr = existingSession!!.cryptoService().verificationService().getExistingTransaction(
existingSession!!.myUserId,
transactionId
) as SasVerificationTransaction
IdlingRegistry.getInstance().register(sasReadyIdle)
IdlingRegistry.getInstance().register(otherSessionSasReadyIdle)
onView(isRoot()).perform(SleepViewAction.sleep(300))
// will only execute when Idle is ready
val expectedEmojis = firstSessionTr.getEmojiCodeRepresentation()
val targets = listOf(R.id.emoji0, R.id.emoji1, R.id.emoji2, R.id.emoji3, R.id.emoji4, R.id.emoji5, R.id.emoji6)
targets.forEachIndexed { index, res ->
onView(withId(res))
.check(
matches(hasDescendant(withText(expectedEmojis[index].nameResId)))
)
}
IdlingRegistry.getInstance().unregister(sasReadyIdle)
IdlingRegistry.getInstance().unregister(otherSessionSasReadyIdle)
val verificationSuccessIdle =
verificationStateIdleResource(transactionId, VerificationTxState.Verified, uiSession)
// CLICK ON THEY MATCH
onView(withId(R.id.bottomSheetVerificationRecyclerView))
.perform(
actionOnItem<RecyclerView.ViewHolder>(
hasDescendant(withText(R.string.verification_sas_match)),
click()
)
)
firstSessionTr.userHasVerifiedShortCode()
onView(isRoot()).perform(SleepViewAction.sleep(1000))
withIdlingResource(verificationSuccessIdle) {
onView(withId(R.id.bottomSheetVerificationRecyclerView))
.check(
matches(hasDescendant(withText(R.string.verification_conclusion_ok_self_notice)))
)
}
// Wait a bit before done (to delay a bit sending of secrets to let other have time
// to mark as verified :/
Thread.sleep(5_000)
// Click on done
onView(withId(R.id.bottomSheetVerificationRecyclerView))
.perform(
actionOnItem<RecyclerView.ViewHolder>(
hasDescendant(withText(R.string.done)),
click()
)
)
// Wait until local secrets are known (gossip)
withIdlingResource(allSecretsKnownIdling(uiSession)) {
onView(withId(R.id.groupToolbarAvatarImageView))
.perform(click())
}
}
fun signout() {
onView((withId(R.id.groupToolbarAvatarImageView)))
.perform(click())
onView((withId(R.id.homeDrawerHeaderSettingsView)))
.perform(click())
onView(withText("General"))
.perform(click())
}
fun verificationStateIdleResource(transactionId: String, checkForState: VerificationTxState, session: Session): IdlingResource {
val idle = object : IdlingResource, VerificationService.Listener {
private var callback: IdlingResource.ResourceCallback? = null
private var currentState: VerificationTxState? = null
override fun getName() = "verificationSuccessIdle"
override fun isIdleNow(): Boolean {
return currentState == checkForState
}
override fun registerIdleTransitionCallback(callback: IdlingResource.ResourceCallback?) {
this.callback = callback
}
fun update(state: VerificationTxState) {
currentState = state
if (state == checkForState) {
session.cryptoService().verificationService().removeListener(this)
callback?.onTransitionToIdle()
}
}
/**
* Called when a transaction is created, either by the user or initiated by the other user.
*/
override fun transactionCreated(tx: VerificationTransaction) {
if (tx.transactionId == transactionId) update(tx.state)
}
/**
* Called when a transaction is updated. You may be interested to track the state of the VerificationTransaction.
*/
override fun transactionUpdated(tx: VerificationTransaction) {
if (tx.transactionId == transactionId) update(tx.state)
}
}
session.cryptoService().verificationService().addListener(idle)
return idle
}
object UITestVerificationUtils
}

View file

@ -0,0 +1,161 @@
/*
* Copyright (c) 2020 New Vector Ltd
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package im.vector.app
import android.view.View
import androidx.recyclerview.widget.RecyclerView
import androidx.test.espresso.Espresso.onView
import androidx.test.espresso.action.ViewActions.click
import androidx.test.espresso.action.ViewActions.closeSoftKeyboard
import androidx.test.espresso.action.ViewActions.typeText
import androidx.test.espresso.assertion.ViewAssertions.matches
import androidx.test.espresso.contrib.RecyclerViewActions.actionOnItem
import androidx.test.espresso.matcher.ViewMatchers.hasDescendant
import androidx.test.espresso.matcher.ViewMatchers.isDisplayed
import androidx.test.espresso.matcher.ViewMatchers.isRoot
import androidx.test.espresso.matcher.ViewMatchers.withId
import androidx.test.espresso.matcher.ViewMatchers.withText
import androidx.test.ext.junit.rules.ActivityScenarioRule
import androidx.test.ext.junit.runners.AndroidJUnit4
import androidx.test.filters.LargeTest
import androidx.test.platform.app.InstrumentationRegistry
import im.vector.app.core.resources.StringProvider
import im.vector.app.features.MainActivity
import im.vector.app.features.crypto.quads.SharedSecureStorageActivity
import im.vector.app.features.crypto.recover.BootstrapCrossSigningTask
import im.vector.app.features.crypto.recover.Params
import im.vector.app.features.home.HomeActivity
import kotlinx.coroutines.runBlocking
import org.junit.Before
import org.junit.Rule
import org.junit.Test
import org.junit.runner.RunWith
import org.matrix.android.sdk.api.Matrix
import org.matrix.android.sdk.api.session.Session
import org.matrix.android.sdk.internal.crypto.model.rest.UserPasswordAuth
@RunWith(AndroidJUnit4::class)
@LargeTest
class VerifySessionPassphraseTest : VerificationTestBase() {
var existingSession: Session? = null
val passphrase = "person woman camera tv"
@get:Rule
val activityRule = ActivityScenarioRule(MainActivity::class.java)
@Before
fun createSessionWithCrossSigningAnd4S() {
val context = InstrumentationRegistry.getInstrumentation().targetContext
val matrix = Matrix.getInstance(context)
val userName = "foobar_${System.currentTimeMillis()}"
existingSession = createAccountAndSync(matrix, userName, password, true)
doSync<Unit> {
existingSession!!.cryptoService().crossSigningService()
.initializeCrossSigning(UserPasswordAuth(
user = existingSession!!.myUserId,
password = "password"
), it)
}
val task = BootstrapCrossSigningTask(existingSession!!, StringProvider(context.resources))
runBlocking {
task.execute(Params(
userPasswordAuth = UserPasswordAuth(password = password),
passphrase = passphrase
))
}
}
@Test
fun checkVerifyWithPassphrase() {
val userId: String = existingSession!!.myUserId
doLogin(homeServerUrl, userId, password)
// Thread.sleep(6000)
withIdlingResource(activityIdlingResource(HomeActivity::class.java)) {
onView(withId(R.id.roomListContainer))
.check(matches(isDisplayed()))
.perform(closeSoftKeyboard())
}
val activity = EspressoHelper.getCurrentActivity()!!
val uiSession = (activity as HomeActivity).activeSessionHolder.getActiveSession()
withIdlingResource(initialSyncIdlingResource(uiSession)) {
onView(withId(R.id.roomListContainer))
.check(matches(isDisplayed()))
}
// THIS IS THE ONLY WAY I FOUND TO CLICK ON ALERTERS... :(
// Cannot wait for view because of alerter animation? ...
Thread.sleep(6000)
val popup = activity.findViewById<View>(com.tapadoo.alerter.R.id.llAlertBackground)
activity.runOnUiThread {
popup.performClick()
}
onView(withId(R.id.bottomSheetFragmentContainer))
.check(matches(isDisplayed()))
onView(isRoot()).perform(SleepViewAction.sleep(2000))
onView(withText(R.string.use_latest_app))
.check(matches(isDisplayed()))
// 4S is not setup so passphrase option should be hidden
onView(withId(R.id.bottomSheetFragmentContainer))
.check(matches(hasDescendant(withText(R.string.verification_cannot_access_other_session))))
onView(withId(R.id.bottomSheetVerificationRecyclerView))
.perform(
actionOnItem<RecyclerView.ViewHolder>(
hasDescendant(withText(R.string.verification_cannot_access_other_session)),
click()
)
)
withIdlingResource(activityIdlingResource(SharedSecureStorageActivity::class.java)) {
onView(withId(R.id.ssss__root)).check(matches(isDisplayed()))
}
onView((withId(R.id.ssss_passphrase_enter_edittext)))
.perform(typeText(passphrase))
onView((withId(R.id.ssss_passphrase_submit)))
.perform(click())
System.out.println("*** passphrase 1")
withIdlingResource(activityIdlingResource(HomeActivity::class.java)) {
System.out.println("*** passphrase 1.1")
onView(withId(R.id.bottomSheetVerificationRecyclerView))
.check(
matches(hasDescendant(withText(R.string.verification_conclusion_ok_self_notice)))
)
}
System.out.println("*** passphrase 2")
// check that all secrets are known?
assert(uiSession.cryptoService().crossSigningService().canCrossSign())
assert(uiSession.cryptoService().crossSigningService().allPrivateKeysKnown())
Thread.sleep(10_000)
}
}

View file

@ -55,6 +55,10 @@ fun isAirplaneModeOn(context: Context): Boolean {
return Settings.Global.getInt(context.contentResolver, Settings.Global.AIRPLANE_MODE_ON, 0) != 0
}
fun isAnimationDisabled(context: Context): Boolean {
return Settings.Global.getFloat(context.contentResolver, Settings.Global.ANIMATOR_DURATION_SCALE, 1f) == 0f
}
/**
* display the system dialog for granting this permission. If previously granted, the
* system will not show it (so you should call this method).

View file

@ -27,6 +27,7 @@ import com.tapadoo.alerter.OnHideAlertListener
import dagger.Lazy
import im.vector.app.R
import im.vector.app.core.platform.VectorBaseActivity
import im.vector.app.core.utils.isAnimationDisabled
import im.vector.app.features.home.AvatarRenderer
import im.vector.app.features.pin.PinActivity
import im.vector.app.features.themes.ThemeUtils
@ -172,6 +173,8 @@ class PopupAlertManager @Inject constructor(private val avatarRenderer: Lazy<Ava
private fun showAlert(alert: VectorAlert, activity: Activity, animate: Boolean = true) {
clearLightStatusBar()
val noAnimation = !animate || isAnimationDisabled(activity)
alert.weakCurrentActivity = WeakReference(activity)
val alerter = if (alert is VerificationVectorAlert) Alerter.create(activity, R.layout.alerter_verification_layout)
else Alerter.create(activity)
@ -187,7 +190,7 @@ class PopupAlertManager @Inject constructor(private val avatarRenderer: Lazy<Ava
}
}
.apply {
if (!animate) {
if (noAnimation) {
setEnterAnimation(R.anim.anim_alerter_no_anim)
}
@ -237,6 +240,7 @@ class PopupAlertManager @Inject constructor(private val avatarRenderer: Lazy<Ava
setBackgroundColorRes(alert.colorRes ?: R.color.notification_accent_color)
}
}
.enableIconPulse(!noAnimation)
.show()
}