diff --git a/app/src/androidTest/java/org/kontalk/ui/PreferencesActivityTest.java b/app/src/androidTest/java/org/kontalk/ui/PreferencesActivityTest.java new file mode 100644 index 000000000..9026376ed --- /dev/null +++ b/app/src/androidTest/java/org/kontalk/ui/PreferencesActivityTest.java @@ -0,0 +1,223 @@ +/* + * Kontalk Android client + * Copyright (C) 2020 Kontalk Devteam + + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +package org.kontalk.ui; + +import android.view.InputDevice; +import android.view.KeyEvent; +import android.view.MotionEvent; +import android.view.View; + +import androidx.test.espresso.Espresso; +import androidx.test.espresso.ViewInteraction; +import androidx.test.espresso.action.GeneralLocation; +import androidx.test.espresso.action.Press; +import androidx.test.espresso.action.Tap; +import androidx.test.espresso.matcher.ViewMatchers; +import androidx.test.filters.LargeTest; +import androidx.test.rule.ActivityTestRule; +import androidx.test.runner.AndroidJUnit4; + +import org.hamcrest.Matcher; +import org.hamcrest.Matchers; +import org.junit.Rule; +import org.junit.Test; +import org.junit.runner.RunWith; +import org.kontalk.R; +import org.kontalk.util.ClickWithoutDisplayConstraint; + +import static androidx.test.espresso.Espresso.onView; +import static androidx.test.espresso.action.ViewActions.pressKey; +import static androidx.test.espresso.action.ViewActions.replaceText; +import static androidx.test.espresso.matcher.ViewMatchers.isDescendantOfA; +import static androidx.test.espresso.matcher.ViewMatchers.isRoot; +import static androidx.test.espresso.matcher.ViewMatchers.withContentDescription; +import static androidx.test.espresso.matcher.ViewMatchers.withHint; +import static androidx.test.espresso.matcher.ViewMatchers.withId; +import static androidx.test.espresso.matcher.ViewMatchers.withText; +import static org.hamcrest.Matchers.allOf; +import static org.hamcrest.Matchers.anyOf; +import static org.kontalk.util.IsEqualTrimmingAndIgnoringCase.equalToTrimmingAndIgnoringCase; +import static org.kontalk.util.VisibleViewMatcher.isVisible; + +@LargeTest +@RunWith(AndroidJUnit4.class) +public class PreferencesActivityTest { + + @Rule + public ActivityTestRule mActivityTestRule = + new ActivityTestRule<>(ConversationsActivity.class); + + @Test + public void preferencesActivityTest() { + ViewInteraction android_widget_EditText = + onView( + Matchers.allOf( + ViewMatchers.withId(R.id.phone_number), + withTextOrHint(equalToTrimmingAndIgnoringCase("5555215554")), + isVisible())); + android_widget_EditText.perform(replaceText("974319006166743")); + + onView(isRoot()).perform(pressKey(KeyEvent.KEYCODE_ENTER)); + + ViewInteraction android_widget_EditText2 = + onView( + allOf( + withId(R.id.name), + withTextOrHint(equalToTrimmingAndIgnoringCase("Your name")), + isVisible())); + android_widget_EditText2.perform(replaceText("crossbar's")); + + onView(isRoot()).perform(pressKey(KeyEvent.KEYCODE_ENTER)); + + Espresso.pressBackUnconditionally(); + + ViewInteraction android_widget_Button = + onView( + allOf( + withId(R.id.button_validate), + withTextOrHint(equalToTrimmingAndIgnoringCase("REGISTER")), + isVisible())); + android_widget_Button.perform(getClickAction()); + + ViewInteraction android_widget_TextView = + onView( + allOf( + withId(R.id.md_buttonDefaultPositive), + withTextOrHint(equalToTrimmingAndIgnoringCase("OK")), + isVisible(), + isDescendantOfA(withId(R.id.md_root)))); + android_widget_TextView.perform(getClickAction()); + + Espresso.pressBackUnconditionally(); + + onView(isRoot()).perform(pressKey(KeyEvent.KEYCODE_ENTER)); + + ViewInteraction android_widget_Spinner = onView(allOf(withId(R.id.phone_cc), isVisible())); + android_widget_Spinner.perform(getClickAction()); + + Espresso.pressBackUnconditionally(); + + ViewInteraction android_widget_EditText3 = + onView( + allOf( + withId(R.id.name), + withTextOrHint(equalToTrimmingAndIgnoringCase("crossbar's")), + isVisible())); + android_widget_EditText3.perform(replaceText("dourines")); + + ViewInteraction android_widget_Button2 = + onView( + allOf( + withId(R.id.button_validate), + withTextOrHint(equalToTrimmingAndIgnoringCase("REGISTER")), + isVisible())); + android_widget_Button2.perform(getClickAction()); + + ViewInteraction android_widget_TextView2 = + onView( + allOf( + withId(R.id.md_buttonDefaultPositive), + withTextOrHint(equalToTrimmingAndIgnoringCase("OK")), + isVisible(), + isDescendantOfA(withId(R.id.md_root)))); + android_widget_TextView2.perform(getClickAction()); + + ViewInteraction android_widget_TextView3 = + onView( + allOf( + withId(R.id.md_content), + withTextOrHint(equalToTrimmingAndIgnoringCase("Requesting registration…")), + isVisible())); + android_widget_TextView3.perform(getClickAction()); + + Espresso.pressBackUnconditionally(); + + ViewInteraction android_widget_Spinner2 = onView(allOf(withId(R.id.phone_cc), isVisible())); + android_widget_Spinner2.perform(getClickAction()); + + Espresso.pressBackUnconditionally(); + + ViewInteraction android_widget_ImageView = + onView( + allOf( + withContentDescription(equalToTrimmingAndIgnoringCase("More options")), + isVisible(), + isDescendantOfA(withId(R.id.toolbar)))); + android_widget_ImageView.perform(getLongClickAction()); + + ViewInteraction android_widget_ImageView2 = + onView( + allOf( + withContentDescription(equalToTrimmingAndIgnoringCase("More options")), + isVisible(), + isDescendantOfA(withId(R.id.toolbar)))); + android_widget_ImageView2.perform(getClickAction()); + + Espresso.pressBackUnconditionally(); + + onView(isRoot()).perform(pressKey(KeyEvent.KEYCODE_ENTER)); + + ViewInteraction android_widget_ImageView3 = + onView( + allOf( + withContentDescription(equalToTrimmingAndIgnoringCase("More options")), + isVisible(), + isDescendantOfA(withId(R.id.toolbar)))); + android_widget_ImageView3.perform(getLongClickAction()); + + ViewInteraction android_widget_EditText4 = + onView( + allOf( + withId(R.id.name), + withTextOrHint(equalToTrimmingAndIgnoringCase("dourines")), + isVisible())); + android_widget_EditText4.perform(replaceText("rewashing")); + + ViewInteraction android_widget_TextView4 = + onView( + allOf(withId(R.id.menu_settings), isVisible(), isDescendantOfA(withId(R.id.toolbar)))); + android_widget_TextView4.perform(getClickAction()); + + Espresso.pressBackUnconditionally(); + + Espresso.pressBackUnconditionally(); + } + + private static Matcher withTextOrHint(final Matcher stringMatcher) { + return anyOf(withText(stringMatcher), withHint(stringMatcher)); + } + + private ClickWithoutDisplayConstraint getClickAction() { + return new ClickWithoutDisplayConstraint( + Tap.SINGLE, + GeneralLocation.VISIBLE_CENTER, + Press.FINGER, + InputDevice.SOURCE_UNKNOWN, + MotionEvent.BUTTON_PRIMARY); + } + + private ClickWithoutDisplayConstraint getLongClickAction() { + return new ClickWithoutDisplayConstraint( + Tap.LONG, + GeneralLocation.CENTER, + Press.FINGER, + InputDevice.SOURCE_UNKNOWN, + MotionEvent.BUTTON_PRIMARY); + } +} diff --git a/app/src/androidTest/java/org/kontalk/util/ClickWithoutDisplayConstraint.java b/app/src/androidTest/java/org/kontalk/util/ClickWithoutDisplayConstraint.java new file mode 100644 index 000000000..5a25cc50a --- /dev/null +++ b/app/src/androidTest/java/org/kontalk/util/ClickWithoutDisplayConstraint.java @@ -0,0 +1,223 @@ +/* + * Kontalk Android client + * Copyright (C) 2020 Kontalk Devteam + + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +package org.kontalk.util; + +import android.util.Log; +import android.view.View; +import android.view.ViewConfiguration; +import android.webkit.WebView; + +import androidx.test.espresso.PerformException; +import androidx.test.espresso.UiController; +import androidx.test.espresso.ViewAction; +import androidx.test.espresso.action.CoordinatesProvider; +import androidx.test.espresso.action.PrecisionDescriber; +import androidx.test.espresso.action.Tap; +import androidx.test.espresso.action.Tapper; +import androidx.test.espresso.util.HumanReadables; + +import org.hamcrest.Matcher; + +import java.util.Locale; +import java.util.Optional; + +import static org.hamcrest.Matchers.allOf; +import static org.kontalk.util.VisibleViewMatcher.isVisible; + +/** + * Custom click action similar to the GeneralClickAction provided by Espresso. + * + * The only difference is that it does not force the target view to be displayed at least 90% on + * screen (i.e., 90% of the view in sight of the user). + * In this custom class, the only constraint is that the view needs to have "Visible" visibility and + * positive height and width. A typical example is when a long form has a visible view at the + * bottom, but the UI needs to be scrolled to reach it. + */ +public final class ClickWithoutDisplayConstraint implements ViewAction { + private static final String TAG = "ClickWithoutDisplayConstraint"; + + final CoordinatesProvider coordinatesProvider; + final Tapper tapper; + final PrecisionDescriber precisionDescriber; + private final Optional rollbackAction; + private final int inputDevice; + private final int buttonState; + + + @Deprecated + public ClickWithoutDisplayConstraint( + Tapper tapper, + CoordinatesProvider coordinatesProvider, + PrecisionDescriber precisionDescriber) { + this(tapper, coordinatesProvider, precisionDescriber, 0, 0, null); + } + + public ClickWithoutDisplayConstraint( + Tapper tapper, + CoordinatesProvider coordinatesProvider, + PrecisionDescriber precisionDescriber, + int inputDevice, + int buttonState) { + this(tapper, coordinatesProvider, precisionDescriber, inputDevice, buttonState, null); + } + + @Deprecated + public ClickWithoutDisplayConstraint( + Tapper tapper, + CoordinatesProvider coordinatesProvider, + PrecisionDescriber precisionDescriber, + ViewAction rollbackAction) { + this(tapper, coordinatesProvider, precisionDescriber, 0, 0, rollbackAction); + } + + public ClickWithoutDisplayConstraint( + Tapper tapper, + CoordinatesProvider coordinatesProvider, + PrecisionDescriber precisionDescriber, + int inputDevice, + int buttonState, + ViewAction rollbackAction) { + this.coordinatesProvider = coordinatesProvider; + this.tapper = tapper; + this.precisionDescriber = precisionDescriber; + this.inputDevice = inputDevice; + this.buttonState = buttonState; + this.rollbackAction = Optional.ofNullable(rollbackAction); + } + + @Override + @SuppressWarnings("unchecked") + public Matcher getConstraints() { + Matcher standardConstraint = isVisible(); + if (rollbackAction.isPresent()) { + return allOf(standardConstraint, rollbackAction.get().getConstraints()); + } else { + return standardConstraint; + } + } + + @Override + public void perform(UiController uiController, View view) { + float[] coordinates = coordinatesProvider.calculateCoordinates(view); + float[] precision = precisionDescriber.describePrecision(); + + Tapper.Status status = Tapper.Status.FAILURE; + int loopCount = 0; + // Native event injection is quite a tricky process. A tap is actually 2 + // seperate motion events which need to get injected into the system. Injection + // makes an RPC call from our app under test to the Android system server, the + // system server decides which window layer to deliver the event to, the system + // server makes an RPC to that window layer, that window layer delivers the event + // to the correct UI element, activity, or window object. Now we need to repeat + // that 2x. for a simple down and up. Oh and the down event triggers timers to + // detect whether or not the event is a long vs. short press. The timers are + // removed the moment the up event is received (NOTE: the possibility of eventTime + // being in the future is totally ignored by most motion event processors). + // + // Phew. + // + // The net result of this is sometimes we'll want to do a regular tap, and for + // whatever reason the up event (last half) of the tap is delivered after long + // press timeout (depending on system load) and the long press behaviour is + // displayed (EG: show a context menu). There is no way to avoid or handle this more + // gracefully. Also the longpress behavour is app/widget specific. So if you have + // a seperate long press behaviour from your short press, you can pass in a + // 'RollBack' ViewAction which when executed will undo the effects of long press. + + while (status != Tapper.Status.SUCCESS && loopCount < 3) { + try { + status = tapper.sendTap(uiController, coordinates, precision, inputDevice, buttonState); + if (Log.isLoggable(TAG, Log.DEBUG)) { + Log.d( + TAG, + "perform: " + + String.format( + Locale.ROOT, + "%s - At Coordinates: %d, %d and precision: %d, %d", + this.getDescription(), + (int) coordinates[0], + (int) coordinates[1], + (int) precision[0], + (int) precision[1])); + } + } catch (RuntimeException re) { + throw new PerformException.Builder() + .withActionDescription( + String.format( + Locale.ROOT, + "%s - At Coordinates: %d, %d and precision: %d, %d", + this.getDescription(), + (int) coordinates[0], + (int) coordinates[1], + (int) precision[0], + (int) precision[1])) + .withViewDescription(HumanReadables.describe(view)) + .withCause(re) + .build(); + } + + int duration = ViewConfiguration.getPressedStateDuration(); + // ensures that all work enqueued to process the tap has been run. + if (duration > 0) { + uiController.loopMainThreadForAtLeast(duration); + } + if (status == Tapper.Status.WARNING) { + if (rollbackAction.isPresent()) { + rollbackAction.get().perform(uiController, view); + } else { + break; + } + } + loopCount++; + } + if (status == Tapper.Status.FAILURE) { + throw new PerformException.Builder() + .withActionDescription(this.getDescription()) + .withViewDescription(HumanReadables.describe(view)) + .withCause( + new RuntimeException( + String.format( + Locale.ROOT, + "Couldn't click at: %s,%s precision: %s, %s . Tapper: %s coordinate" + + " provider: %s precision describer: %s. Tried %s times. With Rollback?" + + " %s", + coordinates[0], + coordinates[1], + precision[0], + precision[1], + tapper, + coordinatesProvider, + precisionDescriber, + loopCount, + rollbackAction.isPresent()))) + .build(); + } + + if (tapper == Tap.SINGLE && view instanceof WebView) { + // WebViews will not process click events until double tap + // timeout. Not the best place for this - but good for now. + uiController.loopMainThreadForAtLeast(ViewConfiguration.getDoubleTapTimeout()); + } + } + + @Override + public String getDescription() { + return tapper.toString().toLowerCase() + " click"; + } +} diff --git a/app/src/androidTest/java/org/kontalk/util/IsEqualTrimmingAndIgnoringCase.java b/app/src/androidTest/java/org/kontalk/util/IsEqualTrimmingAndIgnoringCase.java new file mode 100644 index 000000000..94eb8c0f0 --- /dev/null +++ b/app/src/androidTest/java/org/kontalk/util/IsEqualTrimmingAndIgnoringCase.java @@ -0,0 +1,70 @@ +/* + * Kontalk Android client + * Copyright (C) 2020 Kontalk Devteam + + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +package org.kontalk.util; + +import org.hamcrest.BaseMatcher; +import org.hamcrest.Description; + +/** + * Custom BaseMatcher to match strings ignoring case as well as leading and trailing spaces + */ +public class IsEqualTrimmingAndIgnoringCase extends BaseMatcher { + + private final String string; + + public IsEqualTrimmingAndIgnoringCase(String string) { + if (string == null) { + throw new IllegalArgumentException("Non-null value required by IsEqualTrimmingAndIgnoringCase()"); + } + this.string = string; + } + + public boolean matchesSafely(String item) { + return string.trim().equalsIgnoreCase(item.trim()); + } + + private void describeMismatchSafely(String item, Description mismatchDescription) { + mismatchDescription.appendText("was ").appendText(item); + } + + @Override + public void describeTo(Description description) { + description.appendText("equalToTrimmingAndIgnoringCase(") + .appendValue(string) + .appendText(")"); + } + + public static IsEqualTrimmingAndIgnoringCase equalToTrimmingAndIgnoringCase(String string) { + return new IsEqualTrimmingAndIgnoringCase(string); + } + + @Override + public boolean matches(Object item) { + return item != null && matchesSafely(item.toString()); + } + + @Override + final public void describeMismatch(Object item, Description description) { + if (item == null) { + super.describeMismatch(item, description); + } else { + describeMismatchSafely(item.toString(), description); + } + } +} diff --git a/app/src/androidTest/java/org/kontalk/util/VisibleViewMatcher.java b/app/src/androidTest/java/org/kontalk/util/VisibleViewMatcher.java new file mode 100644 index 000000000..8a7b53f30 --- /dev/null +++ b/app/src/androidTest/java/org/kontalk/util/VisibleViewMatcher.java @@ -0,0 +1,58 @@ +/* + * Kontalk Android client + * Copyright (C) 2020 Kontalk Devteam + + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +package org.kontalk.util; + +import android.view.View; + +import androidx.test.espresso.matcher.ViewMatchers.Visibility; + +import org.hamcrest.Description; +import org.hamcrest.TypeSafeMatcher; + +import static androidx.test.espresso.matcher.ViewMatchers.withEffectiveVisibility; + +/** + * Custom ViewMatcher to match a view that has a "Visible" state but that is not necessarily + * displayed to the user. + * + * Specifically, it matches with views that have "Visible" visibility and positive height and width. + * A typical example is when a long form has a visible view at the bottom, but the UI needs to be + * scrolled to reach it. + */ +public final class VisibleViewMatcher extends TypeSafeMatcher { + + public VisibleViewMatcher() { + super(View.class); + } + + public static VisibleViewMatcher isVisible() { + return new VisibleViewMatcher(); + } + + @Override + protected boolean matchesSafely(View target) { + return withEffectiveVisibility(Visibility.VISIBLE).matches(target) && + target.getWidth() > 0 && target.getHeight() > 0; + } + + @Override + public void describeTo(Description description) { + description.appendText("view has effective visibility VISIBLE and has width and height greater than zero"); + } +}