diff --git a/.github/workflows/actions.yml b/.github/workflows/actions.yml index 3032966..d033756 100644 --- a/.github/workflows/actions.yml +++ b/.github/workflows/actions.yml @@ -27,6 +27,24 @@ jobs: strategy: 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 @@ -43,11 +61,17 @@ 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" + profile: ${{ matrix.profile }} script: ./gradlew connectedAndroidTest diff --git a/app/src/androidTest/java/atorch/statspuzzles/SolvePuzzleTest.java b/app/src/androidTest/java/atorch/statspuzzles/SolvePuzzleTest.java index 5b01474..5b0e083 100644 --- a/app/src/androidTest/java/atorch/statspuzzles/SolvePuzzleTest.java +++ b/app/src/androidTest/java/atorch/statspuzzles/SolvePuzzleTest.java @@ -1,12 +1,25 @@ 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.view.ViewParent; +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 +34,8 @@ 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) public class SolvePuzzleTest { @@ -55,4 +70,128 @@ 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()); + 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) { + 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/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..0794e1f 100644 --- a/app/src/main/java/atorch/statspuzzles/SolvePuzzle.java +++ b/app/src/main/java/atorch/statspuzzles/SolvePuzzle.java @@ -18,16 +18,21 @@ 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; 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 +86,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()); } @@ -241,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) { @@ -248,6 +251,45 @@ 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}; + imeInsetsLayoutListener = () -> { + 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; + }; + rootView.getViewTreeObserver().addOnGlobalLayoutListener(imeInsetsLayoutListener); + Bundle args = getArguments(); level = args.getInt(LEVEL); puzzleIndex = args.getInt(PUZZLE_INDEX); @@ -307,6 +349,41 @@ 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. + 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" />