Fix keyboard covering the answer field on long puzzles - #49
Conversation
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.
There was a problem hiding this comment.
Pull request overview
Updates the puzzle-solving screen to ensure the soft keyboard doesn’t obscure the answer field on newer Android versions by handling IME insets manually, and adds regression coverage in instrumented tests/CI.
Changes:
- Applies IME
WindowInsetsto bottom-pad theScrollViewand scroll the focused view into the visible (unpadded) area. - Adjusts puzzle statement text size for readability.
- Adds an instrumented regression test and extends CI to run instrumented tests on API 35 in addition to the existing API 33 locale matrix.
Reviewed changes
Copilot reviewed 5 out of 5 changed files in this pull request and generated 2 comments.
Show a summary per file
| File | Description |
|---|---|
| app/src/main/res/layout/fragment_solve_puzzle.xml | Allows scrolling content into padded area (clipToPadding=false) and bumps puzzle statement text size. |
| app/src/main/java/atorch/statspuzzles/SolvePuzzle.java | Implements IME-insets padding + manual scroll-to-visible behavior in the solve fragment. |
| app/src/main/AndroidManifest.xml | Sets windowSoftInputMode="adjustResize" for the solve activity. |
| app/src/androidTest/java/atorch/statspuzzles/SolvePuzzleTest.java | Adds a regression test ensuring the answer field isn’t covered by the IME on long puzzles. |
| .github/workflows/actions.yml | Expands instrumented-test coverage to include API 35 runs. |
Suppressed comments (1)
app/src/androidTest/java/atorch/statspuzzles/SolvePuzzleTest.java:188
findAncestorScrollViewcastsgetParent()toViewunconditionally. If there is noScrollViewancestor, this will typically hit aViewRootImplparent and throwClassCastExceptionbefore your intendedIllegalStateException, which makes failures harder to diagnose.
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);
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
…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.
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.
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 5 out of 5 changed files in this pull request and generated no new comments.
Suppressed comments (4)
app/src/main/java/atorch/statspuzzles/SolvePuzzle.java:357
- Removing the OnGlobalLayoutListener should guard against a dead ViewTreeObserver. In some teardown/detach paths, getViewTreeObserver() may no longer be alive and removeOnGlobalLayoutListener can throw, causing a crash during fragment view destruction.
View rootView = getView();
if (rootView != null && imeInsetsLayoutListener != null) {
rootView.getViewTreeObserver().removeOnGlobalLayoutListener(imeInsetsLayoutListener);
}
app/src/androidTest/java/atorch/statspuzzles/SolvePuzzleTest.java:175
- This test creates an ActivityScenario but never closes it. Leaving scenarios open can leak resources and make subsequent instrumented tests flaky; close the scenario when the assertions are done (ideally via try-with-resources or a finally block).
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);
}
app/src/androidTest/java/atorch/statspuzzles/SolvePuzzleTest.java:95
- Fixed Thread.sleep() delays tend to make instrumented tests flaky across devices/CI load. Consider waiting on a deterministic condition (e.g., poll until the target ViewHolder is bound / view is laid out) instead of sleeping a fixed 300ms after waitForIdleSync().
instrumentation.waitForIdleSync();
Thread.sleep(300);
app/src/main/java/atorch/statspuzzles/SolvePuzzle.java:273
- The IME insets listener overwrites the view's existing bottom padding with the IME height. If any baseline bottom padding is added (e.g., from XML, system-bar insets, or future code), it will be lost when the IME shows/hides. Capture the initial bottom padding once and add the IME inset on top of it.
ViewCompat.setOnApplyWindowInsetsListener(rootView, (view, windowInsets) -> {
Insets imeInsets = windowInsets.getInsets(WindowInsetsCompat.Type.ime());
view.setPadding(view.getPaddingLeft(), view.getPaddingTop(), view.getPaddingRight(), imeInsets.bottom);
return windowInsets;
});
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.
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 5 out of 5 changed files in this pull request and generated no new comments.
Suppressed comments (1)
app/src/androidTest/java/atorch/statspuzzles/SolvePuzzleTest.java:175
- ActivityScenario is never closed in this test. Leaving it open can keep the activity alive beyond the test and cause resource leaks / interference with subsequent tests. Prefer closing it (ideally via try-with-resources or a finally block).
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);
}
Summary
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.WindowInsetsdirectly: pads the puzzleScrollViewby the keyboard height and manually scrolls the focused field into the padded visible area.ScrollView's ownrequestRectangleOnScreen()ignores padding, so it can't be used for this.findAncestorScrollViewcould throwClassCastExceptioninstead of the intendedIllegalStateExceptionwhen there's noScrollViewancestor; the newOnGlobalLayoutListenerwas never removed, leaking past fragment view destruction in theViewPager2;findViewHolderForAdapterPosition()could return null before a page is bound, risking a flaky NPE in the new test.android-emulator-runner's device-profile input is namedprofile, notdevice— the old key was silently ignored (just a GitHub Actions warning), so CI had never actually been running on the Pixel 6 profile the workflow intended.RootViewWithoutFocusException: Pixel 6's 1080x2400 framebuffer is too slow to composite under CI's software rendering (the runners have no GPU) within Espresso's 10s window-focus timeout. That leg now uses a smallersmall_phoneprofile instead, which still exercises the edge-to-edge/IME behavior (an OS-version feature, not one that depends on exact screen geometry) without the rendering cost. The API 33 legs keeppixel_6, where it's confirmed passing.Test plan
answerField_notObscuredByKeyboard_whenPuzzleTextIsLong) reproduces the bug on API 35/36 with a real touch tap against the longest text-only puzzle in the app, and passes with the fix applied../gradlew build(compile, unit tests, lint) passes../gradlew connectedAndroidTestpasses on both API 33 and API 36 emulators, no regressions.pixel_6, API 35 onsmall_phone), each genuinely running and passing all 5 tests (not cached/skipped).