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