Skip to content

Fix keyboard covering the answer field on long puzzles - #49

Open
atorch wants to merge 4 commits into
masterfrom
atorch_2026_07_29_keyboard_covers_answer_field
Open

Fix keyboard covering the answer field on long puzzles#49
atorch wants to merge 4 commits into
masterfrom
atorch_2026_07_29_keyboard_covers_answer_field

Conversation

@atorch

@atorch atorch commented Jul 31, 2026

Copy link
Copy Markdown
Owner

Summary

  • 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.
  • Fix consumes the IME WindowInsets directly: pads the puzzle ScrollView by the keyboard height and manually scrolls the focused field into the padded visible area. ScrollView's own requestRectangleOnScreen() ignores padding, so it can't be used for this.
  • Bumps the puzzle text size slightly (14sp → 16sp) for readability.
  • Extends CI to also run instrumented tests on API 35 (in addition to the existing API 33 locale matrix), since API 33 alone never exercises this code path — confirmed the bug doesn't reproduce there even without the fix.
  • Addressed Copilot review feedback: findAncestorScrollView could throw ClassCastException instead of the intended IllegalStateException when there's no ScrollView ancestor; the new OnGlobalLayoutListener was never removed, leaking past fragment view destruction in the ViewPager2; findViewHolderForAdapterPosition() could return null before a page is bound, risking a flaky NPE in the new test.
  • Fixed two CI issues surfaced while getting the API 35 leg green:
    • android-emulator-runner's device-profile input is named profile, not device — 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.
    • Once the profile was actually applied, the API 35 leg started failing every test with 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 smaller small_phone profile 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 keep pixel_6, where it's confirmed passing.

Test plan

  • New instrumented test (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.
  • Verified by screenshot on real emulators: API 33 (AOSP keyboard), API 36 (real Gboard, matching the reporter's Pixel 9) — field visible above the keyboard in both, before/after comparison confirmed the bug and the fix.
  • Full ./gradlew build (compile, unit tests, lint) passes.
  • Full ./gradlew connectedAndroidTest passes on both API 33 and API 36 emulators, no regressions.
  • CI green on all 5 instrumented-test legs (API 33 x 4 locales on pixel_6, API 35 on small_phone), each genuinely running and passing all 5 tests (not cached/skipped).

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.

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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 WindowInsets to bottom-pad the ScrollView and 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

  • findAncestorScrollView casts getParent() to View unconditionally. If there is no ScrollView ancestor, this will typically hit a ViewRootImpl parent and throw ClassCastException before your intended IllegalStateException, 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.

Comment thread app/src/main/java/atorch/statspuzzles/SolvePuzzle.java Outdated
Comment thread app/src/androidTest/java/atorch/statspuzzles/SolvePuzzleTest.java
atorch added 2 commits August 4, 2026 22:33
…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.

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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);
    }

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants