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(); + } + } +}