From 39ecfba3e40286301bd96d7509ee7e1a55e9edbf Mon Sep 17 00:00:00 2001 From: Alexander Bakker Date: Sat, 30 May 2020 13:50:44 +0200 Subject: [PATCH] Add initial set of UI tests This patch adds an initial set of UI tests for Aegis built using Espresso. It covers a fair bit of the essential functionality of the app, but there are lots more tests we could add later on. This also reconfigures our Travis CI build manifest to run the tests on API 21, 23, 27 and 28 emulators. It was a real pain to get this to work well, but let's hope it's stable now. I had to downgrade ``com.google.android.material`` to 1.0.0, because 1.1.0 introduced an issue where the test would hang. --- .travis.yml | 62 ++++- .travis/android-wait-for-launcher.sh | 33 +++ app/build.gradle | 15 +- app/proguard-rules.pro | 2 + .../com/beemdevelopment/aegis/AegisTest.java | 241 ++++++++++++++++++ .../aegis/AegisTestRunner.java | 46 ++++ 6 files changed, 387 insertions(+), 12 deletions(-) create mode 100755 .travis/android-wait-for-launcher.sh create mode 100644 app/src/androidTest/java/com/beemdevelopment/aegis/AegisTest.java create mode 100644 app/src/androidTest/java/com/beemdevelopment/aegis/AegisTestRunner.java diff --git a/.travis.yml b/.travis.yml index 15e27609..92e7ae62 100644 --- a/.travis.yml +++ b/.travis.yml @@ -1,15 +1,55 @@ -language: android +language: shell +os: linux +dist: bionic env: global: - - ANDROID_HOME=/usr/local/android-sdk -android: - components: - - android-29 - - build-tools-29.0.2 + - TERM=dumb + - ABI=x86_64 + - EMU_FLAVOR=default + - ADB_INSTALL_TIMEOUT=16 + - ANDROID_HOME=${HOME}/android + - ANDROID_SDK_ROOT=${ANDROID_HOME} + - TOOLS=${ANDROID_HOME}/cmdline-tools + - PATH=${ANDROID_HOME}/emulator:${TOOLS}/tools/bin:${ANDROID_HOME}/platform-tools:${PATH} + jobs: + - API=21 + - API=23 + #- API=25 <- inconsistent + - API=27 + - API=28 + #- API=29 <- emulator crashes + #- API=R EMU_FLAVOR=google_apis <- too heavy, re-enable when AOSP image is available before_install: - - mkdir -p $ANDROID_HOME/licenses - - echo "8933bad161af4178b1185d1a37fbf41ea5269c55" > $ANDROID_HOME/licenses/android-sdk-license - - echo "d56f5187479451eabf01fb78af6dfcb131a6481e" >> $ANDROID_HOME/licenses/android-sdk-license - - echo "24333f8a63b6825ea9c5514f83c2829b004d1fee" >> $ANDROID_HOME/licenses/android-sdk-license + - mkdir -p ${ANDROID_HOME}/licenses + - echo "8933bad161af4178b1185d1a37fbf41ea5269c55" > ${ANDROID_HOME}/licenses/android-sdk-license + - echo "d56f5187479451eabf01fb78af6dfcb131a6481e" >> ${ANDROID_HOME}/licenses/android-sdk-license + - echo "24333f8a63b6825ea9c5514f83c2829b004d1fee" >> ${ANDROID_HOME}/licenses/android-sdk-license +install: + - mkdir -p ${TOOLS} + - travis_retry wget https://dl.google.com/android/repository/commandlinetools-linux-6514223_latest.zip -O ${TOOLS}/tools.zip + - unzip ${TOOLS}/tools.zip -d ${TOOLS} + - yes | travis_retry sdkmanager "platform-tools" > /dev/null + - yes | travis_retry sdkmanager "tools" > /dev/null + - yes | travis_retry sdkmanager "build-tools;29.0.2" > /dev/null + - yes | travis_retry sdkmanager "platforms;android-29" > /dev/null + - yes | travis_retry sdkmanager "emulator" > /dev/null + - yes | travis_retry sdkmanager "system-images;android-$API;$EMU_FLAVOR;$ABI" > /dev/null + - travis_retry sudo apt-get -yq --no-install-suggests install qemu-kvm +before_script: + - sudo gpasswd -a $USER kvm script: - - ./gradlew build test + - ./gradlew assemble check + - echo no | avdmanager create avd --force -n test -k "system-images;android-$API;$EMU_FLAVOR;$ABI" -d "Nexus 5X" -c 128M + - sudo -E sudo -u $USER -E bash -c "${ANDROID_HOME}/emulator/emulator -verbose -avd test -gpu swiftshader_indirect -no-audio -no-boot-anim -no-snapshot -no-window -camera-back none -camera-front none -selinux permissive -memory 3072 &" + - adb wait-for-device shell 'while [[ -z $(getprop sys.boot_completed) ]]; do sleep 1; done;' + - adb shell settings put global window_animation_scale 0 + - adb shell settings put global transition_animation_scale 0 + - adb shell settings put global animator_duration_scale 0 + - adb reboot + - adb wait-for-device shell 'while [[ -z $(getprop sys.boot_completed) ]]; do sleep 1; done;' + - adb shell input keyevent 82 & + - bash .travis/android-wait-for-launcher.sh + - adb logcat -c + - ./gradlew connectedCheck +after_failure: + - adb logcat -d diff --git a/.travis/android-wait-for-launcher.sh b/.travis/android-wait-for-launcher.sh new file mode 100755 index 00000000..67399a2a --- /dev/null +++ b/.travis/android-wait-for-launcher.sh @@ -0,0 +1,33 @@ +#!/bin/bash + +# source: https://gist.github.com/d4vidi/7862d60375b38f8970f824c4ce0ad2a9 + +echo "" +echo "[Waiting for launcher to start]" +LAUNCHER_READY= +while [[ -z ${LAUNCHER_READY} ]]; do + UI_FOCUS=`adb shell dumpsys activity activities 2>/dev/null | grep -i mResumedActivity` + echo "(DEBUG) Current focus: ${UI_FOCUS}" + + case $UI_FOCUS in + *"Launcher"*) + LAUNCHER_READY=true + ;; + "") + echo "Waiting for window service..." + sleep 3 + ;; + *"Not Responding"*) + echo "Detected an ANR! Dismissing..." + adb shell input keyevent KEYCODE_DPAD_DOWN + adb shell input keyevent KEYCODE_DPAD_DOWN + adb shell input keyevent KEYCODE_ENTER + ;; + *) + echo "Waiting for launcher..." + sleep 3 + ;; + esac +done + +echo "Launcher is ready :-)" diff --git a/app/build.gradle b/app/build.gradle index 4c7d2314..94c2fe78 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -25,6 +25,9 @@ android { multiDexEnabled true buildConfigField "String", "GIT_HASH", "\"${getGitHash()}\"" buildConfigField "String", "GIT_BRANCH", "\"${getGitBranch()}\"" + + testInstrumentationRunner "com.beemdevelopment.aegis.AegisTestRunner" + testInstrumentationRunnerArguments clearPackageData: 'true' } lintOptions { @@ -118,7 +121,7 @@ dependencies { implementation "com.github.topjohnwu.libsu:core:${libsuVersion}" implementation "com.github.topjohnwu.libsu:io:${libsuVersion}" implementation "com.google.guava:guava:${guavaVersion}-android" - implementation 'com.google.android.material:material:1.1.0' + implementation 'com.google.android.material:material:1.0.0' implementation 'com.google.protobuf:protobuf-javalite:3.12.1' implementation "com.mikepenz:iconics-core:3.2.5" implementation 'com.mikepenz:material-design-iconic-typeface:2.2.0.5@aar' @@ -128,6 +131,16 @@ dependencies { implementation 'net.lingala.zip4j:zip4j:2.6.0' implementation 'org.bouncycastle:bcprov-jdk15on:1.65' + 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.espresso:espresso-contrib:3.2.0' + androidTestImplementation 'androidx.test.espresso:espresso-intents:3.2.0' + androidTestImplementation 'junit:junit:4.13' + androidTestUtil 'androidx.test:orchestrator:1.2.0' + testImplementation "com.google.guava:guava:${guavaVersion}-jre" testImplementation "org.junit.jupiter:junit-jupiter-api:${junitVersion}" testRuntimeOnly "org.junit.jupiter:junit-jupiter-engine:${junitVersion}" diff --git a/app/proguard-rules.pro b/app/proguard-rules.pro index 05426e45..e84a179b 100644 --- a/app/proguard-rules.pro +++ b/app/proguard-rules.pro @@ -16,6 +16,8 @@ # public *; #} +-keepclasseswithmembers public class androidx.recyclerview.widget.RecyclerView { *; } + -keep class com.beemdevelopment.aegis.importers.** { *; } -keep class * extends com.google.protobuf.GeneratedMessageLite { *; } diff --git a/app/src/androidTest/java/com/beemdevelopment/aegis/AegisTest.java b/app/src/androidTest/java/com/beemdevelopment/aegis/AegisTest.java new file mode 100644 index 00000000..db3a49e8 --- /dev/null +++ b/app/src/androidTest/java/com/beemdevelopment/aegis/AegisTest.java @@ -0,0 +1,241 @@ +package com.beemdevelopment.aegis; + +import android.view.View; + +import androidx.annotation.IdRes; +import androidx.test.core.app.ApplicationProvider; +import androidx.test.espresso.AmbiguousViewMatcherException; +import androidx.test.espresso.UiController; +import androidx.test.espresso.ViewAction; +import androidx.test.espresso.ViewInteraction; +import androidx.test.espresso.contrib.RecyclerViewActions; +import androidx.test.ext.junit.runners.AndroidJUnit4; +import androidx.test.filters.LargeTest; +import androidx.test.rule.ActivityTestRule; + +import com.beemdevelopment.aegis.crypto.CryptoUtils; +import com.beemdevelopment.aegis.encoding.Base32; +import com.beemdevelopment.aegis.otp.HotpInfo; +import com.beemdevelopment.aegis.otp.OtpInfo; +import com.beemdevelopment.aegis.otp.SteamInfo; +import com.beemdevelopment.aegis.otp.TotpInfo; +import com.beemdevelopment.aegis.ui.MainActivity; +import com.beemdevelopment.aegis.vault.VaultEntry; +import com.beemdevelopment.aegis.vault.VaultManager; +import com.beemdevelopment.aegis.vault.slots.PasswordSlot; + +import org.hamcrest.Matcher; +import org.junit.Rule; +import org.junit.Test; +import org.junit.runner.RunWith; + +import java.lang.reflect.InvocationTargetException; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.List; + +import static androidx.test.espresso.Espresso.onData; +import static androidx.test.espresso.Espresso.onView; +import static androidx.test.espresso.Espresso.openContextualActionModeOverflowMenu; +import static androidx.test.espresso.action.ViewActions.clearText; +import static androidx.test.espresso.action.ViewActions.click; +import static androidx.test.espresso.action.ViewActions.closeSoftKeyboard; +import static androidx.test.espresso.action.ViewActions.longClick; +import static androidx.test.espresso.action.ViewActions.pressBack; +import static androidx.test.espresso.action.ViewActions.typeText; +import static androidx.test.espresso.matcher.ViewMatchers.hasDescendant; +import static androidx.test.espresso.matcher.ViewMatchers.isRoot; +import static androidx.test.espresso.matcher.ViewMatchers.withId; +import static androidx.test.espresso.matcher.ViewMatchers.withText; +import static junit.framework.TestCase.assertFalse; +import static junit.framework.TestCase.assertNull; +import static junit.framework.TestCase.assertTrue; +import static org.hamcrest.Matchers.anything; + +@RunWith(AndroidJUnit4.class) +@LargeTest +public class AegisTest { + private static final String _password = "test"; + private static final String _groupName = "Test"; + + @Rule + public final ActivityTestRule activityRule = new ActivityTestRule<>(MainActivity.class); + + @Test + public void doOverallTest() { + ViewInteraction next = onView(withId(R.id.next)); + next.perform(click()); + onView(withId(R.id.rb_password)).perform(click()); + next.perform(click()); + onView(withId(R.id.text_password)).perform(typeText(_password), closeSoftKeyboard()); + onView(withId(R.id.text_password_confirm)).perform(typeText(_password), closeSoftKeyboard()); + next.perform(click()); + onView(withId(R.id.done)).perform(click()); + + VaultManager vault = getVault(); + assertTrue(vault.isEncryptionEnabled()); + assertTrue(vault.getCredentials().getSlots().has(PasswordSlot.class)); + + List entries = Arrays.asList( + generateEntry(TotpInfo.class, "Frank", "Google"), + generateEntry(HotpInfo.class, "John", "GitHub"), + generateEntry(TotpInfo.class, "Alice", "Office 365"), + generateEntry(SteamInfo.class, "Gaben", "Steam") + ); + for (VaultEntry entry : entries) { + addEntry(entry); + } + + List realEntries = new ArrayList<>(vault.getEntries()); + for (int i = 0; i < realEntries.size(); i++) { + assertTrue(realEntries.get(i).equivalates(entries.get(i))); + } + + for (int i = 0; i < 10; i++) { + onView(withId(R.id.rvKeyProfiles)).perform(RecyclerViewActions.actionOnItemAtPosition(1, clickChildViewWithId(R.id.buttonRefresh))); + } + + onView(withId(R.id.rvKeyProfiles)).perform(RecyclerViewActions.actionOnItemAtPosition(0, longClick())); + onView(withId(R.id.action_copy)).perform(click()); + + onView(withId(R.id.rvKeyProfiles)).perform(RecyclerViewActions.actionOnItemAtPosition(1, longClick())); + onView(withId(R.id.action_edit)).perform(click()); + onView(withId(R.id.text_name)).perform(clearText(), typeText("Bob"), closeSoftKeyboard()); + onView(withId(R.id.spinner_group)).perform(click()); + onData(anything()).atPosition(1).perform(click()); + onView(withId(R.id.text_input)).perform(typeText(_groupName), closeSoftKeyboard()); + onView(withId(android.R.id.button1)).perform(click()); + onView(isRoot()).perform(pressBack()); + onView(withId(android.R.id.button1)).perform(click()); + + changeSort(R.string.sort_alphabetically_name); + changeSort(R.string.sort_alphabetically_name_reverse); + changeSort(R.string.sort_alphabetically); + changeSort(R.string.sort_alphabetically_reverse); + changeSort(R.string.sort_custom); + + changeFilter(_groupName); + changeFilter(R.string.filter_ungrouped); + changeFilter(R.string.all); + + onView(withId(R.id.rvKeyProfiles)).perform(RecyclerViewActions.actionOnItemAtPosition(1, longClick())); + onView(withId(R.id.rvKeyProfiles)).perform(RecyclerViewActions.actionOnItemAtPosition(2, click())); + onView(withId(R.id.rvKeyProfiles)).perform(RecyclerViewActions.actionOnItemAtPosition(3, click())); + onView(withId(R.id.action_share_qr)).perform(click()); + onView(withId(R.id.btnNext)).perform(click()).perform(click()).perform(click()); + + onView(withId(R.id.rvKeyProfiles)).perform(RecyclerViewActions.actionOnItemAtPosition(2, longClick())); + onView(withId(R.id.action_delete)).perform(click()); + onView(withId(android.R.id.button1)).perform(click()); + + openContextualActionModeOverflowMenu(); + onView(withText(R.string.lock)).perform(click()); + onView(withId(R.id.text_password)).perform(typeText(_password), closeSoftKeyboard()); + onView(withId(R.id.button_decrypt)).perform(click()); + vault = getVault(); + + openContextualActionModeOverflowMenu(); + onView(withText(R.string.action_settings)).perform(click()); + onView(withId(androidx.preference.R.id.recycler_view)).perform(RecyclerViewActions.actionOnItem(hasDescendant(withText(R.string.pref_encryption_title)), click())); + onView(withId(android.R.id.button1)).perform(click()); + + assertFalse(vault.isEncryptionEnabled()); + assertNull(vault.getCredentials()); + + onView(withId(androidx.preference.R.id.recycler_view)).perform(RecyclerViewActions.actionOnItem(hasDescendant(withText(R.string.pref_encryption_title)), click())); + onView(withId(R.id.text_password)).perform(typeText(_password), closeSoftKeyboard()); + onView(withId(R.id.text_password_confirm)).perform(typeText(_password), closeSoftKeyboard()); + onView(withId(android.R.id.button1)).perform(click()); + + assertTrue(vault.isEncryptionEnabled()); + assertTrue(vault.getCredentials().getSlots().has(PasswordSlot.class)); + } + + private void changeSort(@IdRes int resId) { + onView(withId(R.id.action_sort)).perform(click()); + onView(withText(resId)).perform(click()); + } + + private void changeFilter(String text) { + openContextualActionModeOverflowMenu(); + onView(withText(R.string.filter)).perform(click()); + onView(withText(text)).perform(click()); + } + + private void changeFilter(@IdRes int resId) { + changeFilter(ApplicationProvider.getApplicationContext().getString(resId)); + } + + private void addEntry(VaultEntry entry) { + onView(withId(R.id.fab_expand_menu_button)).perform(click()); + onView(withId(R.id.fab_enter)).perform(click()); + + onView(withId(R.id.text_name)).perform(typeText(entry.getName()), closeSoftKeyboard()); + onView(withId(R.id.text_issuer)).perform(typeText(entry.getIssuer()), closeSoftKeyboard()); + + if (entry.getInfo().getClass() != TotpInfo.class) { + int i = entry.getInfo() instanceof HotpInfo ? 1 : 2; + try { + onView(withId(R.id.spinner_type)).perform(click()); + onData(anything()).atPosition(i).perform(click()); + } catch (AmbiguousViewMatcherException e) { + // for some reason, clicking twice is sometimes necessary, otherwise the test fails on the next line + onView(withId(R.id.spinner_type)).perform(click()); + onData(anything()).atPosition(i).perform(click()); + } + if (entry.getInfo() instanceof HotpInfo) { + onView(withId(R.id.text_counter)).perform(typeText("0"), closeSoftKeyboard()); + } + if (entry.getInfo() instanceof SteamInfo) { + onView(withId(R.id.text_digits)).perform(clearText(), typeText("5"), closeSoftKeyboard()); + } + } + + String secret = Base32.encode(entry.getInfo().getSecret()); + onView(withId(R.id.text_secret)).perform(typeText(secret), closeSoftKeyboard()); + + onView(withId(R.id.action_save)).perform(click()); + } + + private VaultEntry generateEntry(Class type, String name, String issuer) { + byte[] secret = CryptoUtils.generateRandomBytes(20); + + OtpInfo info; + try { + info = type.getConstructor(byte[].class).newInstance(secret); + } catch (IllegalAccessException | InstantiationException | InvocationTargetException | NoSuchMethodException e) { + throw new RuntimeException(e); + } + + return new VaultEntry(info, name, issuer); + } + + private AegisApplication getApp() { + return (AegisApplication) activityRule.getActivity().getApplication(); + } + + private VaultManager getVault() { + return getApp().getVaultManager(); + } + + // source: https://stackoverflow.com/a/30338665 + private static ViewAction clickChildViewWithId(final int id) { + return new ViewAction() { + @Override + public Matcher getConstraints() { + return null; + } + + @Override + public String getDescription() { + return "Click on a child view with specified id."; + } + + @Override + public void perform(UiController uiController, View view) { + View v = view.findViewById(id); + v.performClick(); + } + }; + } +} diff --git a/app/src/androidTest/java/com/beemdevelopment/aegis/AegisTestRunner.java b/app/src/androidTest/java/com/beemdevelopment/aegis/AegisTestRunner.java new file mode 100644 index 00000000..4a644599 --- /dev/null +++ b/app/src/androidTest/java/com/beemdevelopment/aegis/AegisTestRunner.java @@ -0,0 +1,46 @@ +package com.beemdevelopment.aegis; + +import android.app.Application; +import android.content.Context; +import android.preference.PreferenceManager; + +import androidx.test.core.app.ApplicationProvider; +import androidx.test.runner.AndroidJUnitRunner; + +import java.io.File; + +public class AegisTestRunner extends AndroidJUnitRunner { + @Override + public void callApplicationOnCreate(Application app) { + Context context = app.getApplicationContext(); + + // clear internal storage so that there is no vault file + clearDirectory(context.getFilesDir(), false); + + // clear preferences so that the intro is started from MainActivity + ApplicationProvider.getApplicationContext().getFilesDir(); + PreferenceManager.getDefaultSharedPreferences(context) + .edit() + .clear() + .apply(); + + super.callApplicationOnCreate(app); + } + + private static void clearDirectory(File dir, boolean deleteParent) { + File[] files = dir.listFiles(); + if (files != null) { + for (File file : files) { + if (file.isDirectory()) { + clearDirectory(file, true); + } else { + file.delete(); + } + } + } + + if (deleteParent) { + dir.delete(); + } + } +}