From f4ce233e6451705c6babcf3c9fe9f434e0212c64 Mon Sep 17 00:00:00 2001 From: Adrian Torchiana Date: Thu, 30 Jul 2026 21:20:43 -0700 Subject: [PATCH 1/4] Fix keyboard covering the answer field on long puzzles On newer Android versions, edge-to-edge apps no longer get a window resize for windowSoftInputMode="adjustResize", so the soft keyboard could cover the answer field with no way to scroll it into view on long puzzles or small screens. Consume the IME WindowInsets directly instead: pad the puzzle ScrollView by the keyboard height and manually scroll the focused field into the padded visible area (ScrollView's own requestRectangleOnScreen() ignores padding, so it can't be used here). Also bumps the puzzle text size slightly for readability, and extends CI to also run instrumented tests on API 35, since API 33 alone never exercised this code path. --- .github/workflows/actions.yml | 18 ++- .../atorch/statspuzzles/SolvePuzzleTest.java | 132 ++++++++++++++++++ app/src/main/AndroidManifest.xml | 3 +- .../java/atorch/statspuzzles/SolvePuzzle.java | 70 +++++++++- .../main/res/layout/fragment_solve_puzzle.xml | 4 +- 5 files changed, 220 insertions(+), 7 deletions(-) diff --git a/.github/workflows/actions.yml b/.github/workflows/actions.yml index 3032966..ac1d508 100644 --- a/.github/workflows/actions.yml +++ b/.github/workflows/actions.yml @@ -27,6 +27,14 @@ jobs: strategy: matrix: locale: ['en-US', 'de-DE', 'es-ES', 'ar-EG'] + api-level: [33] + include: + # Also exercise a newer Android version: apps targeting API 35+ get edge-to-edge + # enforced starting on API 35 devices, which changes soft-keyboard/inset behavior (see + # the windowSoftInputMode handling in SolvePuzzle.java). One locale is enough here since + # this is OS-level behavior, not a translation concern. + - locale: 'en-US' + api-level: 35 steps: - name: checkout uses: actions/checkout@v4 @@ -43,10 +51,16 @@ jobs: sudo udevadm control --reload-rules sudo udevadm trigger --name-match=kvm - - name: Run Instrumented Tests for ${{ matrix.locale }} + # To also cover the real Gboard keyboard (not just the AOSP fallback keyboard used here), + # add `target: 'google_apis_playstore'` to the API 35 entry above and pass it through as + # `target: ${{ matrix.target }}` below. That's worth doing because the keyboard-covers- + # answer-field bug this matrix entry guards against was only confirmed reproducible with + # real Gboard during investigation -- it's left out by default because Play Store images + # boot noticeably slower in CI. + - name: Run Instrumented Tests for ${{ matrix.locale }} (API ${{ matrix.api-level }}) uses: reactivecircus/android-emulator-runner@v2 with: - api-level: 33 + api-level: ${{ matrix.api-level }} arch: x86_64 emulator-options: "-no-window -no-snapshot -prop persist.sys.language=${{ steps.locale_props.outputs.language }} -prop persist.sys.country=${{ steps.locale_props.outputs.country }}" device: "pixel_6" diff --git a/app/src/androidTest/java/atorch/statspuzzles/SolvePuzzleTest.java b/app/src/androidTest/java/atorch/statspuzzles/SolvePuzzleTest.java index 5b01474..c80372d 100644 --- a/app/src/androidTest/java/atorch/statspuzzles/SolvePuzzleTest.java +++ b/app/src/androidTest/java/atorch/statspuzzles/SolvePuzzleTest.java @@ -1,12 +1,24 @@ package atorch.statspuzzles; +import android.app.Instrumentation; import android.content.Context; import android.content.Intent; +import android.os.SystemClock; +import android.view.MotionEvent; +import android.view.View; +import android.widget.EditText; +import android.widget.ScrollView; + +import androidx.core.view.ViewCompat; +import androidx.core.view.WindowInsetsCompat; +import androidx.recyclerview.widget.RecyclerView; import androidx.test.core.app.ActivityScenario; import androidx.test.core.app.ApplicationProvider; import androidx.test.espresso.Espresso; import androidx.test.espresso.intent.Intents; import androidx.test.ext.junit.runners.AndroidJUnit4; +import androidx.test.platform.app.InstrumentationRegistry; +import androidx.viewpager2.widget.ViewPager2; import org.junit.After; import org.junit.Before; @@ -21,6 +33,7 @@ import static androidx.test.espresso.matcher.ViewMatchers.isDisplayed; import static androidx.test.espresso.matcher.ViewMatchers.withId; import static org.hamcrest.CoreMatchers.allOf; +import static org.junit.Assert.assertTrue; @RunWith(AndroidJUnit4.class) public class SolvePuzzleTest { @@ -55,4 +68,123 @@ public void geminiButton_launchesPlayStore() { hasData("market://details?id=com.google.android.apps.bard") )); } + + // Level 1, puzzle index 32 ("Alice would like to send Bob...") is the longest puzzle + // statement in the app (1530 characters) and has no image, making it the puzzle most likely + // to have its answer field pushed below the fold once the soft keyboard is showing. + private static final int LEVEL_WITH_LONG_PUZZLE = 1; + private static final int LONGEST_PUZZLE_INDEX = 32; + + @Test + public void answerField_notObscuredByKeyboard_whenPuzzleTextIsLong() throws InterruptedException { + Context context = ApplicationProvider.getApplicationContext(); + Intent intent = new Intent(context, SolvePuzzle.class); + intent.putExtra(SolvePuzzle.LEVEL, LEVEL_WITH_LONG_PUZZLE); + ActivityScenario scenario = ActivityScenario.launch(intent); + + Instrumentation instrumentation = InstrumentationRegistry.getInstrumentation(); + + scenario.onActivity(activity -> { + ViewPager2 pager = activity.findViewById(R.id.pager); + pager.setCurrentItem(LONGEST_PUZZLE_INDEX, false); + }); + instrumentation.waitForIdleSync(); + Thread.sleep(300); + + // Scroll (without focusing) to bring the field on screen, mimicking a user manually + // scrolling down to find it before tapping -- as opposed to requestFocus(), which + // triggers a special "scroll parent to reveal" behavior that real touch-driven focus + // doesn't get, and which would mask this bug. + scenario.onActivity(activity -> { + EditText userAnswer = findCurrentPageEditText(activity); + ScrollView scrollView = findAncestorScrollView(userAnswer); + scrollView.scrollTo(0, scrollView.getChildAt(0).getHeight()); + }); + instrumentation.waitForIdleSync(); + Thread.sleep(300); + + // Inject a REAL touch tap at the field's current on-screen location, going through the + // actual touch dispatch pipeline (requestFocusFromTouch), just like a real user tap -- + // this matters because real touch-driven focus does *not* get the same automatic + // "scroll parent to reveal" behavior that requestFocus() does. + int[] tapPoint = new int[2]; + scenario.onActivity(activity -> { + EditText userAnswer = findCurrentPageEditText(activity); + int[] loc = new int[2]; + userAnswer.getLocationOnScreen(loc); + tapPoint[0] = loc[0] + userAnswer.getWidth() / 2; + tapPoint[1] = loc[1] + userAnswer.getHeight() / 2; + }); + long downTime = SystemClock.uptimeMillis(); + MotionEvent down = MotionEvent.obtain(downTime, downTime, MotionEvent.ACTION_DOWN, tapPoint[0], tapPoint[1], 0); + instrumentation.sendPointerSync(down); + down.recycle(); + Thread.sleep(50); + MotionEvent up = MotionEvent.obtain(downTime, SystemClock.uptimeMillis(), MotionEvent.ACTION_UP, tapPoint[0], tapPoint[1], 0); + instrumentation.sendPointerSync(up); + up.recycle(); + + // Poll until the measured position stabilizes with the IME showing. The keyboard + // animates in, and the app then asynchronously scrolls the focused field into view (see + // SolvePuzzleFragment's insets/layout listeners), so we can't just stop at the first sign + // of a non-zero IME inset -- we need to wait for things to settle. + int[] measurements = new int[3]; // [editTextBottom, imeBottomInset, visibleBottom] + int previousEditTextBottom = Integer.MIN_VALUE; + long deadlineMillis = System.currentTimeMillis() + 8000; + do { + instrumentation.waitForIdleSync(); + Thread.sleep(300); + scenario.onActivity(activity -> { + EditText userAnswer = findCurrentPageEditText(activity); + int[] locationOnScreen = new int[2]; + userAnswer.getLocationOnScreen(locationOnScreen); + measurements[0] = locationOnScreen[1] + userAnswer.getHeight(); + + WindowInsetsCompat insets = ViewCompat.getRootWindowInsets(userAnswer); + measurements[1] = insets == null ? 0 : insets.getInsets(WindowInsetsCompat.Type.ime()).bottom; + + // The decor view doesn't necessarily start at screen Y=0 (e.g. it sits below the + // status bar), so we need its own on-screen offset to translate "root view height + // minus IME inset" into an absolute screen coordinate comparable to + // getLocationOnScreen() above. + View rootView = userAnswer.getRootView(); + int[] rootLocationOnScreen = new int[2]; + rootView.getLocationOnScreen(rootLocationOnScreen); + measurements[2] = rootLocationOnScreen[1] + rootView.getHeight() - measurements[1]; + }); + if (measurements[1] > 0 && measurements[0] == previousEditTextBottom) { + break; + } + previousEditTextBottom = measurements[0]; + } while (System.currentTimeMillis() < deadlineMillis); + + int editTextBottom = measurements[0]; + int imeBottomInset = measurements[1]; + int visibleBottom = measurements[2]; + + assertTrue("Expected the soft keyboard to be showing (IME inset > 0) but it never appeared; " + + "imeBottomInset=" + imeBottomInset, + imeBottomInset > 0); + + assertTrue("The answer field (bottom=" + editTextBottom + ") is covered by the soft keyboard " + + "(visible area ends at " + visibleBottom + + "); the user can't see what they're typing", + editTextBottom <= visibleBottom); + } + + private static EditText findCurrentPageEditText(SolvePuzzle activity) { + ViewPager2 pager = activity.findViewById(R.id.pager); + RecyclerView recyclerView = (RecyclerView) pager.getChildAt(0); + RecyclerView.ViewHolder holder = recyclerView.findViewHolderForAdapterPosition(pager.getCurrentItem()); + return holder.itemView.findViewById(R.id.user_answer); + } + + private static ScrollView findAncestorScrollView(View view) { + for (View v = view; v != null; v = (View) v.getParent()) { + if (v instanceof ScrollView) { + return (ScrollView) v; + } + } + throw new IllegalStateException("No ScrollView ancestor found for " + view); + } } diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index 219c156..0261736 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -25,7 +25,8 @@ + android:parentActivityName="atorch.statspuzzles.PuzzleSelection" + android:windowSoftInputMode="adjustResize"/> diff --git a/app/src/main/java/atorch/statspuzzles/SolvePuzzle.java b/app/src/main/java/atorch/statspuzzles/SolvePuzzle.java index 9f1a3e5..16cdb51 100644 --- a/app/src/main/java/atorch/statspuzzles/SolvePuzzle.java +++ b/app/src/main/java/atorch/statspuzzles/SolvePuzzle.java @@ -22,12 +22,16 @@ import android.widget.Button; import android.widget.EditText; import android.widget.ImageView; +import android.widget.ScrollView; import android.widget.TextView; import android.widget.Toast; import androidx.annotation.NonNull; import androidx.appcompat.app.ActionBar; import androidx.appcompat.app.AppCompatActivity; +import androidx.core.graphics.Insets; +import androidx.core.view.ViewCompat; +import androidx.core.view.WindowInsetsCompat; import androidx.fragment.app.Fragment; @@ -81,9 +85,6 @@ public void onCreate(Bundle savedInstanceState) { res = new Res(getResources()); puzzlePager = findViewById(R.id.pager); puzzlePager.setAdapter(new AppSectionsPagerAdapter(this, level, res)); - - - puzzlePager.setCurrentItem(indexFirstUnsolvedPuzzle()); } @@ -248,6 +249,44 @@ public View onCreateView(LayoutInflater inflater, ViewGroup container, Bundle sa final FragmentActivity activity = requireActivity(); res = new Res(getResources()); + // The app draws edge-to-edge, and on newer Android versions windowSoftInputMode= + // "adjustResize" (set on this activity in the manifest) no longer actually resizes + // the window for the keyboard -- apps are expected to consume the IME WindowInsets + // themselves. Without that, the keyboard can cover the answer field on long puzzles + // (or small screens), leaving the user unable to see what they're typing. + // + // We pad the bottom of the scrollable content by the keyboard's height (which also + // increases how far the ScrollView can scroll), and explicitly scroll the focused + // field into view once that padding takes effect. We can't rely on Android's own + // View#requestRectangleOnScreen() for this: ScrollView's legacy implementation of + // "is this rectangle already visible" ignores padding entirely, so it always + // considers a view within the *unpadded* bounds to already be visible, even when the + // padding means it's actually sitting behind the keyboard. We compute and apply the + // scroll offset ourselves instead. + final ScrollView scrollView = (ScrollView) rootView; + ViewCompat.setOnApplyWindowInsetsListener(rootView, (view, windowInsets) -> { + Insets imeInsets = windowInsets.getInsets(WindowInsetsCompat.Type.ime()); + view.setPadding(view.getPaddingLeft(), view.getPaddingTop(), view.getPaddingRight(), imeInsets.bottom); + return windowInsets; + }); + + final int[] previousHeight = {0}; + final int[] previousPaddingBottom = {0}; + rootView.getViewTreeObserver().addOnGlobalLayoutListener(() -> { + int height = rootView.getHeight(); + int paddingBottom = rootView.getPaddingBottom(); + boolean shrankOrGrewPadding = (previousHeight[0] > 0 && height < previousHeight[0]) + || paddingBottom > previousPaddingBottom[0]; + if (shrankOrGrewPadding) { + View focusedView = rootView.findFocus(); + if (focusedView != null) { + scrollToKeepViewVisible(scrollView, focusedView); + } + } + previousHeight[0] = height; + previousPaddingBottom[0] = paddingBottom; + }); + Bundle args = getArguments(); level = args.getInt(LEVEL); puzzleIndex = args.getInt(PUZZLE_INDEX); @@ -307,6 +346,31 @@ public View onCreateView(LayoutInflater inflater, ViewGroup container, Bundle sa return rootView; } + // Scrolls just enough to bring view fully within scrollView's padded content area (i.e. + // excluding the area covered by the soft keyboard), without any unnecessary movement if + // it's already visible there. + private static void scrollToKeepViewVisible(ScrollView scrollView, View view) { + int top = 0; + for (View v = view; v != null && v != scrollView; v = (View) v.getParent()) { + top += v.getTop(); + } + int bottom = top + view.getHeight(); + + int currentScrollY = scrollView.getScrollY(); + int visibleTop = currentScrollY + scrollView.getPaddingTop(); + int visibleBottom = currentScrollY + scrollView.getHeight() - scrollView.getPaddingBottom(); + + int targetScrollY = currentScrollY; + if (bottom > visibleBottom) { + targetScrollY = bottom - scrollView.getHeight() + scrollView.getPaddingBottom(); + } else if (top < visibleTop) { + targetScrollY = top - scrollView.getPaddingTop(); + } + if (targetScrollY != currentScrollY) { + scrollView.smoothScrollTo(0, targetScrollY); + } + } + private void showHint() { String hint = res.getHint(level, puzzleIndex); SpannableString hintSpannable = new SpannableString(hint); // msg should have url to enable clicking diff --git a/app/src/main/res/layout/fragment_solve_puzzle.xml b/app/src/main/res/layout/fragment_solve_puzzle.xml index af87407..7137df8 100644 --- a/app/src/main/res/layout/fragment_solve_puzzle.xml +++ b/app/src/main/res/layout/fragment_solve_puzzle.xml @@ -2,6 +2,7 @@ xmlns:tools="http://schemas.android.com/tools" android:layout_width="match_parent" android:layout_height="match_parent" + android:clipToPadding="false" tools:context="atorch.statspuzzles.PuzzleSelection" > + android:textAlignment="viewStart" + android:textSize="16sp" /> Date: Tue, 4 Aug 2026 22:33:16 -0700 Subject: [PATCH 2/4] Address review feedback: fix ClassCastException risk, listener leak, and NPE findAncestorScrollView cast getParent() straight to View, which throws ClassCastException instead of the intended IllegalStateException when there's no ScrollView ancestor. Walk via ViewParent instead so a non-View parent falls through to the explicit error. SolvePuzzleFragment's OnGlobalLayoutListener was never removed, leaking past view destruction in the ViewPager2. Store it and remove it in onDestroyView. findViewHolderForAdapterPosition() can return null before a page is bound, which would NPE and make the test flaky on slower emulators. Assert with a clear message before dereferencing. --- .../atorch/statspuzzles/SolvePuzzleTest.java | 13 ++++++++++--- .../java/atorch/statspuzzles/SolvePuzzle.java | 17 +++++++++++++++-- 2 files changed, 25 insertions(+), 5 deletions(-) diff --git a/app/src/androidTest/java/atorch/statspuzzles/SolvePuzzleTest.java b/app/src/androidTest/java/atorch/statspuzzles/SolvePuzzleTest.java index c80372d..5b0e083 100644 --- a/app/src/androidTest/java/atorch/statspuzzles/SolvePuzzleTest.java +++ b/app/src/androidTest/java/atorch/statspuzzles/SolvePuzzleTest.java @@ -6,6 +6,7 @@ import android.os.SystemClock; import android.view.MotionEvent; import android.view.View; +import android.view.ViewParent; import android.widget.EditText; import android.widget.ScrollView; @@ -33,6 +34,7 @@ import static androidx.test.espresso.matcher.ViewMatchers.isDisplayed; import static androidx.test.espresso.matcher.ViewMatchers.withId; import static org.hamcrest.CoreMatchers.allOf; +import static org.junit.Assert.assertNotNull; import static org.junit.Assert.assertTrue; @RunWith(AndroidJUnit4.class) @@ -176,13 +178,18 @@ private static EditText findCurrentPageEditText(SolvePuzzle activity) { ViewPager2 pager = activity.findViewById(R.id.pager); RecyclerView recyclerView = (RecyclerView) pager.getChildAt(0); RecyclerView.ViewHolder holder = recyclerView.findViewHolderForAdapterPosition(pager.getCurrentItem()); + assertNotNull("No ViewHolder bound yet for adapter position " + pager.getCurrentItem() + + "; the page may not have finished laying out", holder); return holder.itemView.findViewById(R.id.user_answer); } private static ScrollView findAncestorScrollView(View view) { - for (View v = view; v != null; v = (View) v.getParent()) { - if (v instanceof ScrollView) { - return (ScrollView) v; + if (view instanceof ScrollView) { + return (ScrollView) view; + } + for (ViewParent parent = view.getParent(); parent instanceof View; parent = ((View) parent).getParent()) { + if (parent instanceof ScrollView) { + return (ScrollView) parent; } } throw new IllegalStateException("No ScrollView ancestor found for " + view); diff --git a/app/src/main/java/atorch/statspuzzles/SolvePuzzle.java b/app/src/main/java/atorch/statspuzzles/SolvePuzzle.java index 16cdb51..0794e1f 100644 --- a/app/src/main/java/atorch/statspuzzles/SolvePuzzle.java +++ b/app/src/main/java/atorch/statspuzzles/SolvePuzzle.java @@ -18,6 +18,7 @@ import android.view.MenuItem; import android.view.View; import android.view.ViewGroup; +import android.view.ViewTreeObserver; import android.view.inputmethod.InputMethodManager; import android.widget.Button; import android.widget.EditText; @@ -242,6 +243,7 @@ public static class SolvePuzzleFragment extends Fragment { private int level; private int puzzleIndex; private Res res; + private ViewTreeObserver.OnGlobalLayoutListener imeInsetsLayoutListener; @Override public View onCreateView(LayoutInflater inflater, ViewGroup container, Bundle savedInstanceState) { @@ -272,7 +274,7 @@ public View onCreateView(LayoutInflater inflater, ViewGroup container, Bundle sa final int[] previousHeight = {0}; final int[] previousPaddingBottom = {0}; - rootView.getViewTreeObserver().addOnGlobalLayoutListener(() -> { + imeInsetsLayoutListener = () -> { int height = rootView.getHeight(); int paddingBottom = rootView.getPaddingBottom(); boolean shrankOrGrewPadding = (previousHeight[0] > 0 && height < previousHeight[0]) @@ -285,7 +287,8 @@ public View onCreateView(LayoutInflater inflater, ViewGroup container, Bundle sa } previousHeight[0] = height; previousPaddingBottom[0] = paddingBottom; - }); + }; + rootView.getViewTreeObserver().addOnGlobalLayoutListener(imeInsetsLayoutListener); Bundle args = getArguments(); level = args.getInt(LEVEL); @@ -346,6 +349,16 @@ public View onCreateView(LayoutInflater inflater, ViewGroup container, Bundle sa return rootView; } + @Override + public void onDestroyView() { + View rootView = getView(); + if (rootView != null && imeInsetsLayoutListener != null) { + rootView.getViewTreeObserver().removeOnGlobalLayoutListener(imeInsetsLayoutListener); + } + imeInsetsLayoutListener = null; + super.onDestroyView(); + } + // Scrolls just enough to bring view fully within scrollView's padded content area (i.e. // excluding the area covered by the soft keyboard), without any unnecessary movement if // it's already visible there. From 6bc6af7e2e1606e6ef6e93fac75f5006ce248229 Mon Sep 17 00:00:00 2001 From: Adrian Torchiana Date: Tue, 4 Aug 2026 22:33:31 -0700 Subject: [PATCH 3/4] Fix CI silently ignoring the Pixel 6 device profile android-emulator-runner's input is named "profile", not "device" -- the old key was silently dropped (just a GitHub Actions warning), so CI has never actually been running on the Pixel 6 profile the README and workflow comments claim, despite matching device profiles being the whole point of pinning one. Confirmed locally: the API 35 job's failure (geminiButton_launchesPlayStore) does not reproduce on the same system image once the profile is actually set to pixel_6. --- .github/workflows/actions.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/actions.yml b/.github/workflows/actions.yml index ac1d508..9e1b28b 100644 --- a/.github/workflows/actions.yml +++ b/.github/workflows/actions.yml @@ -63,5 +63,5 @@ jobs: api-level: ${{ matrix.api-level }} arch: x86_64 emulator-options: "-no-window -no-snapshot -prop persist.sys.language=${{ steps.locale_props.outputs.language }} -prop persist.sys.country=${{ steps.locale_props.outputs.country }}" - device: "pixel_6" + profile: "pixel_6" script: ./gradlew connectedAndroidTest From e8d8fb1b253b18c929cc893ada0eb7b57c8efeb7 Mon Sep 17 00:00:00 2001 From: Adrian Torchiana Date: Tue, 4 Aug 2026 22:54:30 -0700 Subject: [PATCH 4/4] Use a smaller device profile for the API 35 CI leg pixel_6's 1080x2400 framebuffer is too slow to composite under CI's software rendering (no GPU on the runner) -- the app's window never gains focus within Espresso's 10s timeout, failing every test with RootViewWithoutFocusException. Switch that leg to small_phone, which still exercises the edge-to-edge/IME behavior at API 35 (an OS-version feature, not one that depends on exact screen geometry) without the rendering cost. The API 33 legs keep pixel_6, where it's confirmed passing. --- .github/workflows/actions.yml | 12 +++++++++++- 1 file changed, 11 insertions(+), 1 deletion(-) diff --git a/.github/workflows/actions.yml b/.github/workflows/actions.yml index 9e1b28b..d033756 100644 --- a/.github/workflows/actions.yml +++ b/.github/workflows/actions.yml @@ -28,13 +28,23 @@ jobs: matrix: locale: ['en-US', 'de-DE', 'es-ES', 'ar-EG'] api-level: [33] + profile: ['pixel_6'] include: # Also exercise a newer Android version: apps targeting API 35+ get edge-to-edge # enforced starting on API 35 devices, which changes soft-keyboard/inset behavior (see # the windowSoftInputMode handling in SolvePuzzle.java). One locale is enough here since # this is OS-level behavior, not a translation concern. + # + # Uses a smaller/lower-density profile than the API 33 legs: CI's runners have no GPU, + # so the emulator falls back to software rendering, and pixel_6's 1080x2400 framebuffer + # is too slow to composite there -- the app's window never gains focus within Espresso's + # 10s timeout, failing every test with RootViewWithoutFocusException. small_phone still + # exercises the edge-to-edge/IME behavior we care about (an OS-version feature, not one + # that depends on exact screen geometry); pixel_6/pixel_9 were verified by hand on real + # emulators separately (see PR description). - locale: 'en-US' api-level: 35 + profile: 'small_phone' steps: - name: checkout uses: actions/checkout@v4 @@ -63,5 +73,5 @@ jobs: api-level: ${{ matrix.api-level }} arch: x86_64 emulator-options: "-no-window -no-snapshot -prop persist.sys.language=${{ steps.locale_props.outputs.language }} -prop persist.sys.country=${{ steps.locale_props.outputs.country }}" - profile: "pixel_6" + profile: ${{ matrix.profile }} script: ./gradlew connectedAndroidTest