diff --git a/.github/workflows/android.yml b/.github/workflows/android.yml index f3b3e2788..30fcb8b89 100644 --- a/.github/workflows/android.yml +++ b/.github/workflows/android.yml @@ -28,6 +28,9 @@ env: jobs: build: runs-on: ubuntu-latest + strategy: + matrix: + api-level: [ 21, 34 ] steps: - uses: actions/checkout@v4.1.7 - name: Fail on bad translations @@ -44,11 +47,22 @@ jobs: run: ./gradlew lintRelease - name: Run unit tests run: timeout 5m ./gradlew testReleaseUnitTest || { ./gradlew --stop && timeout 5m ./gradlew testReleaseUnitTest; } + - name: Enable KVM + run: | + echo 'KERNEL=="kvm", GROUP="kvm", MODE="0666", OPTIONS+="static_node=kvm"' | sudo tee /etc/udev/rules.d/99-kvm4all.rules + sudo udevadm control --reload-rules + sudo udevadm trigger --name-match=kvm + - name: Run instrumented tests + uses: ReactiveCircus/android-emulator-runner@v2 + with: + api-level: ${{ matrix.api-level }} + arch: x86_64 + script: ./gradlew connectedCheck - name: SpotBugs run: ./gradlew spotbugsRelease - name: Archive test results if: always() uses: actions/upload-artifact@v4.3.6 with: - name: test-results + name: test-results-api${{ matrix.api-level }} path: app/build/reports diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 3dc0cabc0..e1d21cd1a 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -28,6 +28,8 @@ android { multiDexEnabled = true resourceConfigurations += listOf("ar", "bg", "bn", "bn-rIN", "bs", "cs", "da", "de", "el-rGR", "en", "eo", "es", "es-rAR", "et", "fi", "fr", "he-rIL", "hi", "hr", "hu", "in-rID", "is", "it", "ja", "ko", "lt", "lv", "nb-rNO", "nl", "oc", "pl", "pt-rBR", "pt-rPT", "ro-rRO", "ru", "sk", "sl", "sr", "sv", "tr", "uk", "vi", "zh-rCN", "zh-rTW") + + testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" } buildTypes { @@ -110,9 +112,18 @@ dependencies { implementation("io.wcm.tooling.spotbugs:io.wcm.tooling.spotbugs.annotations:1.0.0") // Testing - testImplementation("androidx.test:core:1.6.1") - testImplementation("junit:junit:4.13.2") + val androidXTestVersion = "1.6.1" + val junitVersion = "4.13.2" + testImplementation("androidx.test:core:$androidXTestVersion") + testImplementation("junit:junit:$junitVersion") testImplementation("org.robolectric:robolectric:4.13") + + androidTestImplementation("androidx.test:core:$androidXTestVersion") + androidTestImplementation("junit:junit:$junitVersion") + androidTestImplementation("androidx.test.ext:junit:1.2.1") + androidTestImplementation("androidx.test:runner:$androidXTestVersion") + androidTestImplementation("androidx.test.uiautomator:uiautomator:2.3.0") + androidTestImplementation("androidx.test.espresso:espresso-core:3.6.1") } tasks.withType().configureEach { diff --git a/app/src/androidTest/java/protect/card_locker/MainActivitySearchViewTest.java b/app/src/androidTest/java/protect/card_locker/MainActivitySearchViewTest.java new file mode 100644 index 000000000..6ca306a1d --- /dev/null +++ b/app/src/androidTest/java/protect/card_locker/MainActivitySearchViewTest.java @@ -0,0 +1,67 @@ +package protect.card_locker; + +import static androidx.test.espresso.Espresso.onView; +import static androidx.test.espresso.action.ViewActions.click; +import static androidx.test.espresso.action.ViewActions.typeText; +import static androidx.test.espresso.assertion.ViewAssertions.matches; +import static androidx.test.espresso.matcher.ViewMatchers.isDisplayed; +import static androidx.test.espresso.matcher.ViewMatchers.withChild; +import static androidx.test.espresso.matcher.ViewMatchers.withId; +import static androidx.test.espresso.matcher.ViewMatchers.withText; +import static org.hamcrest.Matchers.is; +import static org.hamcrest.Matchers.not; +import static org.junit.Assert.assertEquals; + +import androidx.appcompat.widget.Toolbar; +import androidx.test.core.app.ActivityScenario; +import androidx.test.ext.junit.runners.AndroidJUnit4; +import androidx.test.platform.app.InstrumentationRegistry; +import androidx.test.uiautomator.UiDevice; + +import org.junit.Test; +import org.junit.runner.RunWith; + +@RunWith(AndroidJUnit4.class) +public class MainActivitySearchViewTest { + + @Test + public void whenSearchViewIsExpandedAndBackIsPressedThenMenuItemShouldNotBeCollapsed() { + String query = "random arbitrary text"; + try (ActivityScenario mainActivityScenario = ActivityScenario.launch(MainActivity.class)) { + mainActivityScenario.onActivity(this::makeSearchMenuItemVisible); + onView(withId(R.id.action_search)).perform(click()); + onView(withId(androidx.appcompat.R.id.search_src_text)).perform(typeText(query)); + + pressBack(); + + onView(withId(androidx.appcompat.R.id.search_src_text)).check(matches(withText(query))); + mainActivityScenario.onActivity(activity -> assertEquals(query, activity.mFilter)); + } + } + + @Test + public void whenSearchViewIsExpandedThenItShouldOnlyBeCollapsedWhenBackIsPressedTwice() { + try (ActivityScenario mainActivityScenario = ActivityScenario.launch(MainActivity.class)) { + mainActivityScenario.onActivity(this::makeSearchMenuItemVisible); + onView(withId(R.id.action_search)).perform(click()); + + pressBack(); + + onView(withId(androidx.appcompat.R.id.search_src_text)).check(matches(isDisplayed())); + + pressBack(); + + onView(withId(android.R.id.content)).check(matches(is(not(withChild(withId(androidx.appcompat.R.id.search_src_text)))))); + } + } + + private void makeSearchMenuItemVisible(MainActivity activity) { + Toolbar toolbar = activity.findViewById(R.id.toolbar); + toolbar.getMenu().findItem(R.id.action_search).setVisible(true); + } + + private void pressBack() { + UiDevice.getInstance(InstrumentationRegistry.getInstrumentation()).pressBack(); + } + +} diff --git a/app/src/main/java/protect/card_locker/MainActivity.java b/app/src/main/java/protect/card_locker/MainActivity.java index d66dfa4ed..a10f1178d 100644 --- a/app/src/main/java/protect/card_locker/MainActivity.java +++ b/app/src/main/java/protect/card_locker/MainActivity.java @@ -7,6 +7,7 @@ import android.content.Intent; import android.content.SharedPreferences; import android.database.CursorIndexOutOfBoundsException; import android.database.sqlite.SQLiteDatabase; +import android.os.Build; import android.os.Bundle; import android.util.DisplayMetrics; import android.util.Log; @@ -21,6 +22,7 @@ import android.widget.Toast; import androidx.activity.OnBackPressedCallback; import androidx.activity.result.ActivityResultLauncher; import androidx.activity.result.contract.ActivityResultContracts; +import androidx.annotation.NonNull; import androidx.appcompat.app.AlertDialog; import androidx.appcompat.view.ActionMode; import androidx.appcompat.widget.SearchView; @@ -511,7 +513,8 @@ public class MainActivity extends CatimaAppCompatActivity implements LoyaltyCard SearchManager searchManager = (SearchManager) getSystemService(Context.SEARCH_SERVICE); if (searchManager != null) { - mSearchView = (SearchView) inputMenu.findItem(R.id.action_search).getActionView(); + MenuItem searchMenuItem = inputMenu.findItem(R.id.action_search); + mSearchView = (SearchView) searchMenuItem.getActionView(); mSearchView.setSearchableInfo(searchManager.getSearchableInfo(getComponentName())); mSearchView.setSubmitButtonEnabled(false); @@ -520,6 +523,30 @@ public class MainActivity extends CatimaAppCompatActivity implements LoyaltyCard return false; }); + /* + * On Android 13 and later, pressing Back while the search view is open hides the keyboard + * and collapses the search view at the same time. + * This brings back the old behavior on Android 12 and lower: pressing Back once + * hides the keyboard, press again while keyboard is hidden to collapse the search view. + */ + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { + searchMenuItem.setOnActionExpandListener(new MenuItem.OnActionExpandListener() { + @Override + public boolean onMenuItemActionExpand(@NonNull MenuItem item) { + return true; + } + + @Override + public boolean onMenuItemActionCollapse(@NonNull MenuItem item) { + if (mSearchView.hasFocus()) { + mSearchView.clearFocus(); + return false; + } + return true; + } + }); + } + mSearchView.setOnQueryTextListener(new SearchView.OnQueryTextListener() { @Override public boolean onQueryTextSubmit(String query) {