From bc2c345e215dfeca0725db923c6e23fc9b9e920a Mon Sep 17 00:00:00 2001 From: Valere Date: Fri, 25 Sep 2020 08:58:48 +0200 Subject: [PATCH 1/4] First automated UI tests --- .../crypto/OutgoingGossipingRequestManager.kt | 2 +- .../crypto/store/db/RealmCryptoStore.kt | 4 + vector/build.gradle | 27 +- .../java/im/vector/app/ExpressoExt.kt | 205 +++++++++++++ .../java/im/vector/app/RegistrationTest.kt | 120 ++++++++ .../java/im/vector/app/SleepViewAction.java | 49 ++++ .../java/im/vector/app/TestMatrixCallback.kt | 48 ++++ .../im/vector/app/VerificationTestBase.kt | 225 +++++++++++++++ .../app/VerifySessionInteractiveTest.kt | 272 ++++++++++++++++++ .../vector/app/VerifySessionPassphraseTest.kt | 161 +++++++++++ .../app/features/popup/PopupAlertManager.kt | 8 +- 11 files changed, 1114 insertions(+), 7 deletions(-) create mode 100644 vector/src/androidTest/java/im/vector/app/ExpressoExt.kt create mode 100644 vector/src/androidTest/java/im/vector/app/RegistrationTest.kt create mode 100644 vector/src/androidTest/java/im/vector/app/SleepViewAction.java create mode 100644 vector/src/androidTest/java/im/vector/app/TestMatrixCallback.kt create mode 100644 vector/src/androidTest/java/im/vector/app/VerificationTestBase.kt create mode 100644 vector/src/androidTest/java/im/vector/app/VerifySessionInteractiveTest.kt create mode 100644 vector/src/androidTest/java/im/vector/app/VerifySessionPassphraseTest.kt diff --git a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/crypto/OutgoingGossipingRequestManager.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/crypto/OutgoingGossipingRequestManager.kt index 030560b77f..d1aeed7da1 100755 --- a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/crypto/OutgoingGossipingRequestManager.kt +++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/crypto/OutgoingGossipingRequestManager.kt @@ -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, diff --git a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/crypto/store/db/RealmCryptoStore.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/crypto/store/db/RealmCryptoStore.kt index 4f3f06beac..24de3cfe63 100644 --- a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/crypto/store/db/RealmCryptoStore.kt +++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/crypto/store/db/RealmCryptoStore.kt @@ -372,6 +372,7 @@ internal class RealmCryptoStore @Inject constructor( } override fun storePrivateKeysInfo(msk: String?, usk: String?, ssk: String?) { + Timber.v("## CRYPTO | *** storePrivateKeysInfo ${msk != null} ") doRealmTransaction(realmConfiguration) { realm -> realm.where().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().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().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().findFirst()?.apply { xSignUserPrivateKey = usk diff --git a/vector/build.gradle b/vector/build.gradle index 0c7985b45d..f9e485a0f9 100644 --- a/vector/build.gradle +++ b/vector/build.gradle @@ -172,6 +172,22 @@ 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 { @@ -428,11 +444,12 @@ dependencies { // 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 'androidx.test:core:1.3.0' + androidTestImplementation 'androidx.test:runner:1.3.0' + androidTestImplementation 'androidx.test:rules:1.3.0' + androidTestImplementation 'androidx.test.ext:junit:1.1.2' + androidTestImplementation 'androidx.test.espresso:espresso-core:3.3.0' + androidTestImplementation 'androidx.test.espresso:espresso-contrib:3.3.0' androidTestImplementation 'org.amshove.kluent:kluent-android:1.44' androidTestImplementation "androidx.arch.core:core-testing:$arch_version" // Plant Timber tree for test diff --git a/vector/src/androidTest/java/im/vector/app/ExpressoExt.kt b/vector/src/androidTest/java/im/vector/app/ExpressoExt.kt new file mode 100644 index 0000000000..5bd42bda39 --- /dev/null +++ b/vector/src/androidTest/java/im/vector/app/ExpressoExt.kt @@ -0,0 +1,205 @@ +/* + * 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 { run { currentActivity = ActivityLifecycleMonitorRegistry.getInstance().getActivitiesInStage(Stage.RESUMED).elementAtOrNull(0) } } + return currentActivity + } +} + +fun waitForView(viewMatcher: Matcher, timeout: Long = 10000, waitForDisplayed: Boolean = true): ViewAction { + return object : ViewAction { + override fun getConstraints(): Matcher { + 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) { + System.out.println("*** waitForView 1 $view") + uiController.loopMainThreadUntilIdle() + val startTime = System.currentTimeMillis() + val endTime = startTime + timeout + val visibleMatcher = isDisplayed() + + do { + System.out.println("*** waitForView loop $view end:$endTime currrent:${System.currentTimeMillis()}") + val viewVisible = TreeIterables.breadthFirstViewTraversal(view) + .any { viewMatcher.matches(it) && visibleMatcher.matches(it) } + + System.out.println("*** waitForView loop viewVisible:$viewVisible") + if (viewVisible == waitForDisplayed) return + System.out.println("*** waitForView loop loopMainThreadForAtLeast...") + uiController.loopMainThreadForAtLeast(50) + System.out.println("*** waitForView loop ...loopMainThreadForAtLeast") + } while (System.currentTimeMillis() < endTime) + + System.out.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 { + 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 + System.out.println("*** [$name] isIdleNow activityIdlingResource $currentActivity isIdle:$isIdle") + return isIdle + } + + override fun registerIdleTransitionCallback(callback: IdlingResource.ResourceCallback?) { + System.out.println("*** [$name] registerIdleTransitionCallback $callback") + this.callback = callback + // if (hasResumed) callback?.onTransitionToIdle() + } + + override fun onActivityLifecycleChanged(activity: Activity?, stage: Stage?) { + System.out.println("*** [$name] onActivityLifecycleChanged $activity $stage") + currentActivity = ActivityLifecycleMonitorRegistry.getInstance().getActivitiesInStage(Stage.RESUMED).elementAtOrNull(0) + val isIdle = currentActivity?.javaClass?.let { activityClass.isAssignableFrom(it) } ?: false + System.out.println("*** [$name] onActivityLifecycleChanged $currentActivity isIdle:$isIdle") + if (isIdle) { + hasResumed = true + System.out.println("*** [$name] onActivityLifecycleChanged callback: $callback") + callback?.onTransitionToIdle() + ActivityLifecycleMonitorRegistry.getInstance().removeLifecycleCallback(this) + } + } + } + ActivityLifecycleMonitorRegistry.getInstance().addLifecycleCallback(res) + return res +} + +fun withIdlingResource(idlingResource: IdlingResource, block: (() -> Unit)) { + System.out.println("*** withIdlingResource register") + IdlingRegistry.getInstance().register(idlingResource) + block.invoke() + System.out.println("*** withIdlingResource unregister") + IdlingRegistry.getInstance().unregister(idlingResource) +} + +fun allSecretsKnownIdling(session: Session): IdlingResource { + val res = object : IdlingResource, Observer> { + private var callback: IdlingResource.ResourceCallback? = null + + var privateKeysInfo: PrivateKeysInfo? = session.cryptoService().crossSigningService().getCrossSigningPrivateKeys() + override fun getName() = "AllSecretsKnownIdling_${session.myUserId}" + + override fun isIdleNow(): Boolean { + System.out.println("*** [$name]/isIdleNow allSecretsKnownIdling ${privateKeysInfo?.allKnown()}") + return privateKeysInfo?.allKnown() == true + } + + override fun registerIdleTransitionCallback(callback: IdlingResource.ResourceCallback?) { + this.callback = callback + } + + override fun onChanged(t: Optional?) { + System.out.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 +} diff --git a/vector/src/androidTest/java/im/vector/app/RegistrationTest.kt b/vector/src/androidTest/java/im/vector/app/RegistrationTest.kt new file mode 100644 index 0000000000..016d25da78 --- /dev/null +++ b/vector/src/androidTest/java/im/vector/app/RegistrationTest.kt @@ -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 splashcreen 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 showned + 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(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())) + } + } +} diff --git a/vector/src/androidTest/java/im/vector/app/SleepViewAction.java b/vector/src/androidTest/java/im/vector/app/SleepViewAction.java new file mode 100644 index 0000000000..8623f24756 --- /dev/null +++ b/vector/src/androidTest/java/im/vector/app/SleepViewAction.java @@ -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 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); + } + }; + } +} \ No newline at end of file diff --git a/vector/src/androidTest/java/im/vector/app/TestMatrixCallback.kt b/vector/src/androidTest/java/im/vector/app/TestMatrixCallback.kt new file mode 100644 index 0000000000..2e254d48ef --- /dev/null +++ b/vector/src/androidTest/java/im/vector/app/TestMatrixCallback.kt @@ -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 + */ +open class TestMatrixCallback(private val countDownLatch: CountDownLatch, + private val onlySuccessful: Boolean = true) : MatrixCallback { + + @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() + } +} diff --git a/vector/src/androidTest/java/im/vector/app/VerificationTestBase.kt b/vector/src/androidTest/java/im/vector/app/VerificationTestBase.kt new file mode 100644 index 0000000000..015f561920 --- /dev/null +++ b/vector/src/androidTest/java/im/vector/app/VerificationTestBase.kt @@ -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.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 { + matrix.authenticationService() + .getLoginFlow(hs, it) + } + + doSync { + matrix.authenticationService() + .getRegistrationWizard() + .createAccount(userName, password, null, it) + } + + // Preform dummy step + val registrationResult = doSync { + 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 doSync(block: (MatrixCallback) -> Unit): T { + val lock = CountDownLatch(1) + var result: T? = null + + val callback = object : TestMatrixCallback(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 { + 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) + } +} diff --git a/vector/src/androidTest/java/im/vector/app/VerifySessionInteractiveTest.kt b/vector/src/androidTest/java/im/vector/app/VerifySessionInteractiveTest.kt new file mode 100644 index 0000000000..d218b6ef7e --- /dev/null +++ b/vector/src/androidTest/java/im/vector/app/VerifySessionInteractiveTest.kt @@ -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 { + 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(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( + 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( + 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( + 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 +} diff --git a/vector/src/androidTest/java/im/vector/app/VerifySessionPassphraseTest.kt b/vector/src/androidTest/java/im/vector/app/VerifySessionPassphraseTest.kt new file mode 100644 index 0000000000..5405c086eb --- /dev/null +++ b/vector/src/androidTest/java/im/vector/app/VerifySessionPassphraseTest.kt @@ -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 { + 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(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( + 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) + } +} diff --git a/vector/src/main/java/im/vector/app/features/popup/PopupAlertManager.kt b/vector/src/main/java/im/vector/app/features/popup/PopupAlertManager.kt index 593527448b..814a7ca16e 100644 --- a/vector/src/main/java/im/vector/app/features/popup/PopupAlertManager.kt +++ b/vector/src/main/java/im/vector/app/features/popup/PopupAlertManager.kt @@ -20,6 +20,7 @@ import android.app.Activity import android.os.Build import android.os.Handler import android.os.Looper +import android.provider.Settings import android.view.View import android.widget.ImageView import com.tapadoo.alerter.Alerter @@ -172,6 +173,10 @@ class PopupAlertManager @Inject constructor(private val avatarRenderer: Lazy Date: Fri, 25 Sep 2020 09:34:13 +0200 Subject: [PATCH 2/4] Doc + change log --- CHANGES.md | 1 + docs/ui-tests.md | 87 ++++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 88 insertions(+) create mode 100644 docs/ui-tests.md diff --git a/CHANGES.md b/CHANGES.md index 25da8f0108..e5542996ff 100644 --- a/CHANGES.md +++ b/CHANGES.md @@ -49,6 +49,7 @@ SDK API changes ⚠️: Other changes: - Add an advanced action to reset an account data entry + - Added registration/verification automated UI tests Changes in Element 1.0.7 (2020-09-17) =================================================== diff --git a/docs/ui-tests.md b/docs/ui-tests.md new file mode 100644 index 0000000000..9757481991 --- /dev/null +++ b/docs/ui-tests.md @@ -0,0 +1,87 @@ +# 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: + +``` +$ 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: + +``` +$ 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 + + +## Recipes + +We added some specific Espresso IdlingResources, and other utilities for matrix related tests + +### Wait for initial sync + +```` +// Wait for initial sync and check room list is there +withIdlingResource(initialSyncIdlingResource(uiSession)) { + onView(withId(R.id.roomListContainer)) + .check(matches(isDisplayed())) +} +```` + +### Accessing current activity + +```` + 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) + +```` +@Before +fun initAccount() { + val context = InstrumentationRegistry.getInstrumentation().targetContext + val matrix = Matrix.getInstance(context) + val userName = "foobar_${System.currentTimeMillis()}" + existingSession = createAccountAndSync(matrix, userName, password, true) +} +````` From f79784bc8c2bb91a6cad0b199bf0c9eec4f884e4 Mon Sep 17 00:00:00 2001 From: Valere Date: Fri, 25 Sep 2020 09:45:34 +0200 Subject: [PATCH 3/4] Stabilisation Hide keyboard before entering text --- vector/src/androidTest/java/im/vector/app/RegistrationTest.kt | 2 +- .../src/androidTest/java/im/vector/app/VerificationTestBase.kt | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/vector/src/androidTest/java/im/vector/app/RegistrationTest.kt b/vector/src/androidTest/java/im/vector/app/RegistrationTest.kt index 016d25da78..a290aeec80 100644 --- a/vector/src/androidTest/java/im/vector/app/RegistrationTest.kt +++ b/vector/src/androidTest/java/im/vector/app/RegistrationTest.kt @@ -96,7 +96,7 @@ class RegistrationTest { // Ensure password onView((withId(R.id.passwordField))) - .perform(typeText(password)) + .perform(closeSoftKeyboard(), typeText(password)) // Submit onView(withId(R.id.loginSubmit)) diff --git a/vector/src/androidTest/java/im/vector/app/VerificationTestBase.kt b/vector/src/androidTest/java/im/vector/app/VerificationTestBase.kt index 015f561920..20d0053a9f 100644 --- a/vector/src/androidTest/java/im/vector/app/VerificationTestBase.kt +++ b/vector/src/androidTest/java/im/vector/app/VerificationTestBase.kt @@ -84,7 +84,7 @@ abstract class VerificationTestBase { .check(ViewAssertions.matches(CoreMatchers.not(ViewMatchers.isEnabled()))) Espresso.onView((ViewMatchers.withId(R.id.passwordField))) - .perform(ViewActions.typeText(password)) + .perform(ViewActions.closeSoftKeyboard(), ViewActions.typeText(password)) Espresso.onView(ViewMatchers.withId(R.id.loginSubmit)) .check(ViewAssertions.matches(ViewMatchers.isEnabled())) From b14d22550bd161594407ff06826ce8371a1dd075 Mon Sep 17 00:00:00 2001 From: Benoit Marty Date: Tue, 29 Sep 2020 15:12:25 +0200 Subject: [PATCH 4/4] PR Review Cleanup and Add command line to run the UI tests --- CHANGES.md | 3 +- docs/ui-tests.md | 40 ++++++++++++++----- .../android/sdk/common/CommonTestHelper.kt | 2 +- .../crypto/store/db/RealmCryptoStore.kt | 2 +- vector/build.gradle | 22 +++++----- .../app/{ExpressoExt.kt => EspressoExt.kt} | 36 +++++++++-------- .../java/im/vector/app/RegistrationTest.kt | 4 +- .../im/vector/app/VerificationTestBase.kt | 2 +- .../im/vector/app/core/utils/SystemUtils.kt | 4 ++ .../app/features/popup/PopupAlertManager.kt | 10 ++--- 10 files changed, 75 insertions(+), 50 deletions(-) rename vector/src/androidTest/java/im/vector/app/{ExpressoExt.kt => EspressoExt.kt} (82%) diff --git a/CHANGES.md b/CHANGES.md index e5542996ff..efbab5bd27 100644 --- a/CHANGES.md +++ b/CHANGES.md @@ -20,7 +20,7 @@ Build 🧱: - Other changes: - - + - Added registration/verification automated UI tests Changes in Element 1.0.8 (2020-09-25) =================================================== @@ -49,7 +49,6 @@ SDK API changes ⚠️: Other changes: - Add an advanced action to reset an account data entry - - Added registration/verification automated UI tests Changes in Element 1.0.7 (2020-09-17) =================================================== diff --git a/docs/ui-tests.md b/docs/ui-tests.md index 9757481991..ff01da0b31 100644 --- a/docs/ui-tests.md +++ b/docs/ui-tests.md @@ -16,20 +16,20 @@ Out of the box, the tests use one of the homeservers (located at http://localhos 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 .` +(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` +(env) $ demo/start.sh --no-rate-limit ``` **Emulator/Device set up** @@ -50,33 +50,53 @@ On your device, under **Settings > Developer options**, disable the following 3 - 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 @@ -84,4 +104,4 @@ fun initAccount() { val userName = "foobar_${System.currentTimeMillis()}" existingSession = createAccountAndSync(matrix, userName, password, true) } -````` +``` diff --git a/matrix-sdk-android/src/androidTest/java/org/matrix/android/sdk/common/CommonTestHelper.kt b/matrix-sdk-android/src/androidTest/java/org/matrix/android/sdk/common/CommonTestHelper.kt index fdbfa57b5c..84e76cbe52 100644 --- a/matrix-sdk-android/src/androidTest/java/org/matrix/android/sdk/common/CommonTestHelper.kt +++ b/matrix-sdk-android/src/androidTest/java/org/matrix/android/sdk/common/CommonTestHelper.kt @@ -218,7 +218,7 @@ class CommonTestHelper(context: Context) { .createAccount(userName, password, null, it) } - // Preform dummy step + // Perform dummy step val registrationResult = doSync { matrix.authenticationService .getRegistrationWizard() diff --git a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/crypto/store/db/RealmCryptoStore.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/crypto/store/db/RealmCryptoStore.kt index 24de3cfe63..df71ef9eba 100644 --- a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/crypto/store/db/RealmCryptoStore.kt +++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/crypto/store/db/RealmCryptoStore.kt @@ -372,7 +372,7 @@ internal class RealmCryptoStore @Inject constructor( } override fun storePrivateKeysInfo(msk: String?, usk: String?, ssk: String?) { - Timber.v("## CRYPTO | *** storePrivateKeysInfo ${msk != null} ") + Timber.v("## CRYPTO | *** storePrivateKeysInfo ${msk != null}, ${usk != null}, ${ssk != null}") doRealmTransaction(realmConfiguration) { realm -> realm.where().findFirst()?.apply { xSignMasterPrivateKey = msk diff --git a/vector/build.gradle b/vector/build.gradle index f9e485a0f9..9191ce640c 100644 --- a/vector/build.gradle +++ b/vector/build.gradle @@ -173,15 +173,12 @@ android { } } - // 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.” @@ -297,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") @@ -437,20 +439,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.3.0' - androidTestImplementation 'androidx.test:runner:1.3.0' - androidTestImplementation 'androidx.test:rules:1.3.0' + 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:3.3.0' - androidTestImplementation 'androidx.test.espresso:espresso-contrib:3.3.0' - androidTestImplementation 'org.amshove.kluent:kluent-android:1.44' + 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' diff --git a/vector/src/androidTest/java/im/vector/app/ExpressoExt.kt b/vector/src/androidTest/java/im/vector/app/EspressoExt.kt similarity index 82% rename from vector/src/androidTest/java/im/vector/app/ExpressoExt.kt rename to vector/src/androidTest/java/im/vector/app/EspressoExt.kt index 5bd42bda39..d247d88caa 100644 --- a/vector/src/androidTest/java/im/vector/app/ExpressoExt.kt +++ b/vector/src/androidTest/java/im/vector/app/EspressoExt.kt @@ -44,12 +44,14 @@ import java.util.concurrent.TimeoutException object EspressoHelper { fun getCurrentActivity(): Activity? { var currentActivity: Activity? = null - getInstrumentation().runOnMainSync { run { currentActivity = ActivityLifecycleMonitorRegistry.getInstance().getActivitiesInStage(Stage.RESUMED).elementAtOrNull(0) } } + getInstrumentation().runOnMainSync { + currentActivity = ActivityLifecycleMonitorRegistry.getInstance().getActivitiesInStage(Stage.RESUMED).elementAtOrNull(0) + } return currentActivity } } -fun waitForView(viewMatcher: Matcher, timeout: Long = 10000, waitForDisplayed: Boolean = true): ViewAction { +fun waitForView(viewMatcher: Matcher, timeout: Long = 10_000, waitForDisplayed: Boolean = true): ViewAction { return object : ViewAction { override fun getConstraints(): Matcher { return Matchers.any(View::class.java) @@ -62,25 +64,25 @@ fun waitForView(viewMatcher: Matcher, timeout: Long = 10000, waitForDispla } override fun perform(uiController: UiController, view: View) { - System.out.println("*** waitForView 1 $view") + println("*** waitForView 1 $view") uiController.loopMainThreadUntilIdle() val startTime = System.currentTimeMillis() val endTime = startTime + timeout val visibleMatcher = isDisplayed() do { - System.out.println("*** waitForView loop $view end:$endTime currrent:${System.currentTimeMillis()}") + println("*** waitForView loop $view end:$endTime current:${System.currentTimeMillis()}") val viewVisible = TreeIterables.breadthFirstViewTraversal(view) .any { viewMatcher.matches(it) && visibleMatcher.matches(it) } - System.out.println("*** waitForView loop viewVisible:$viewVisible") + println("*** waitForView loop viewVisible:$viewVisible") if (viewVisible == waitForDisplayed) return - System.out.println("*** waitForView loop loopMainThreadForAtLeast...") + println("*** waitForView loop loopMainThreadForAtLeast...") uiController.loopMainThreadForAtLeast(50) - System.out.println("*** waitForView loop ...loopMainThreadForAtLeast") + println("*** waitForView loop ...loopMainThreadForAtLeast") } while (System.currentTimeMillis() < endTime) - System.out.println("*** waitForView timeout $view") + println("*** waitForView timeout $view") // Timeout happens. throw PerformException.Builder() .withActionDescription(this.description) @@ -136,24 +138,24 @@ fun activityIdlingResource(activityClass: Class<*>): IdlingResource { val currentActivity = currentActivity ?: ActivityLifecycleMonitorRegistry.getInstance().getActivitiesInStage(Stage.RESUMED).elementAtOrNull(0) val isIdle = hasResumed || currentActivity?.javaClass?.let { activityClass.isAssignableFrom(it) } ?: false - System.out.println("*** [$name] isIdleNow activityIdlingResource $currentActivity isIdle:$isIdle") + println("*** [$name] isIdleNow activityIdlingResource $currentActivity isIdle:$isIdle") return isIdle } override fun registerIdleTransitionCallback(callback: IdlingResource.ResourceCallback?) { - System.out.println("*** [$name] registerIdleTransitionCallback $callback") + println("*** [$name] registerIdleTransitionCallback $callback") this.callback = callback // if (hasResumed) callback?.onTransitionToIdle() } override fun onActivityLifecycleChanged(activity: Activity?, stage: Stage?) { - System.out.println("*** [$name] onActivityLifecycleChanged $activity $stage") + println("*** [$name] onActivityLifecycleChanged $activity $stage") currentActivity = ActivityLifecycleMonitorRegistry.getInstance().getActivitiesInStage(Stage.RESUMED).elementAtOrNull(0) val isIdle = currentActivity?.javaClass?.let { activityClass.isAssignableFrom(it) } ?: false - System.out.println("*** [$name] onActivityLifecycleChanged $currentActivity isIdle:$isIdle") + println("*** [$name] onActivityLifecycleChanged $currentActivity isIdle:$isIdle") if (isIdle) { hasResumed = true - System.out.println("*** [$name] onActivityLifecycleChanged callback: $callback") + println("*** [$name] onActivityLifecycleChanged callback: $callback") callback?.onTransitionToIdle() ActivityLifecycleMonitorRegistry.getInstance().removeLifecycleCallback(this) } @@ -164,10 +166,10 @@ fun activityIdlingResource(activityClass: Class<*>): IdlingResource { } fun withIdlingResource(idlingResource: IdlingResource, block: (() -> Unit)) { - System.out.println("*** withIdlingResource register") + println("*** withIdlingResource register") IdlingRegistry.getInstance().register(idlingResource) block.invoke() - System.out.println("*** withIdlingResource unregister") + println("*** withIdlingResource unregister") IdlingRegistry.getInstance().unregister(idlingResource) } @@ -179,7 +181,7 @@ fun allSecretsKnownIdling(session: Session): IdlingResource { override fun getName() = "AllSecretsKnownIdling_${session.myUserId}" override fun isIdleNow(): Boolean { - System.out.println("*** [$name]/isIdleNow allSecretsKnownIdling ${privateKeysInfo?.allKnown()}") + println("*** [$name]/isIdleNow allSecretsKnownIdling ${privateKeysInfo?.allKnown()}") return privateKeysInfo?.allKnown() == true } @@ -188,7 +190,7 @@ fun allSecretsKnownIdling(session: Session): IdlingResource { } override fun onChanged(t: Optional?) { - System.out.println("*** [$name] allSecretsKnownIdling ${t?.getOrNull()}") + println("*** [$name] allSecretsKnownIdling ${t?.getOrNull()}") privateKeysInfo = t?.getOrNull() if (t?.getOrNull()?.allKnown() == true) { session.cryptoService().crossSigningService().getLiveCrossSigningPrivateKeys().removeObserver(this) diff --git a/vector/src/androidTest/java/im/vector/app/RegistrationTest.kt b/vector/src/androidTest/java/im/vector/app/RegistrationTest.kt index a290aeec80..b88356db59 100644 --- a/vector/src/androidTest/java/im/vector/app/RegistrationTest.kt +++ b/vector/src/androidTest/java/im/vector/app/RegistrationTest.kt @@ -48,7 +48,7 @@ class RegistrationTest { val password: String = "password" val homeServerUrl: String = "http://10.0.2.2:8080" - // Check splashcreen is there + // Check splashscreen is there onView(withId(R.id.loginSplashSubmit)) .check(matches(isDisplayed())) .check(matches(withText(R.string.login_splash_submit))) @@ -57,7 +57,7 @@ class RegistrationTest { onView(withId(R.id.loginSplashSubmit)) .perform(click()) - // Check that home server options are showned + // Check that home server options are shown onView(withId(R.id.loginServerTitle)) .check(matches(isDisplayed())) .check(matches(withText(R.string.login_server_title))) diff --git a/vector/src/androidTest/java/im/vector/app/VerificationTestBase.kt b/vector/src/androidTest/java/im/vector/app/VerificationTestBase.kt index 20d0053a9f..2a1b6d802f 100644 --- a/vector/src/androidTest/java/im/vector/app/VerificationTestBase.kt +++ b/vector/src/androidTest/java/im/vector/app/VerificationTestBase.kt @@ -158,7 +158,7 @@ abstract class VerificationTestBase { .createAccount(userName, password, null, it) } - // Preform dummy step + // Perform dummy step val registrationResult = doSync { matrix.authenticationService() .getRegistrationWizard() diff --git a/vector/src/main/java/im/vector/app/core/utils/SystemUtils.kt b/vector/src/main/java/im/vector/app/core/utils/SystemUtils.kt index 0e722da34a..c1253e76d3 100644 --- a/vector/src/main/java/im/vector/app/core/utils/SystemUtils.kt +++ b/vector/src/main/java/im/vector/app/core/utils/SystemUtils.kt @@ -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). diff --git a/vector/src/main/java/im/vector/app/features/popup/PopupAlertManager.kt b/vector/src/main/java/im/vector/app/features/popup/PopupAlertManager.kt index 814a7ca16e..59386ffff0 100644 --- a/vector/src/main/java/im/vector/app/features/popup/PopupAlertManager.kt +++ b/vector/src/main/java/im/vector/app/features/popup/PopupAlertManager.kt @@ -20,7 +20,6 @@ import android.app.Activity import android.os.Build import android.os.Handler import android.os.Looper -import android.provider.Settings import android.view.View import android.widget.ImageView import com.tapadoo.alerter.Alerter @@ -28,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 @@ -173,9 +173,7 @@ class PopupAlertManager @Inject constructor(private val avatarRenderer: Lazy