Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
30 changes: 27 additions & 3 deletions .github/workflows/actions.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
139 changes: 139 additions & 0 deletions app/src/androidTest/java/atorch/statspuzzles/SolvePuzzleTest.java
Original file line number Diff line number Diff line change
@@ -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;
Expand All @@ -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 {
Expand Down Expand Up @@ -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<SolvePuzzle> 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);
}
}
3 changes: 2 additions & 1 deletion app/src/main/AndroidManifest.xml
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,8 @@
<activity
android:name=".SolvePuzzle"
android:label="@string/title_activity_solve_puzzle"
android:parentActivityName="atorch.statspuzzles.PuzzleSelection"/>
android:parentActivityName="atorch.statspuzzles.PuzzleSelection"
android:windowSoftInputMode="adjustResize"/>
</application>

</manifest>
83 changes: 80 additions & 3 deletions app/src/main/java/atorch/statspuzzles/SolvePuzzle.java
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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());
}

Expand Down Expand Up @@ -241,13 +243,53 @@ 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) {
View rootView = inflater.inflate(R.layout.fragment_solve_puzzle, container, false);
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);
Expand Down Expand Up @@ -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
Expand Down
4 changes: 3 additions & 1 deletion app/src/main/res/layout/fragment_solve_puzzle.xml
Original file line number Diff line number Diff line change
Expand Up @@ -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" >
<LinearLayout
android:layout_width="match_parent"
Expand Down Expand Up @@ -35,7 +36,8 @@
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:gravity="start"
android:textAlignment="viewStart" />
android:textAlignment="viewStart"
android:textSize="16sp" />

<ImageView android:contentDescription="@string/check_mark_description"
android:layout_marginTop="@dimen/activity_vertical_margin"
Expand Down
Loading