Android: keep the keyboard's bottom row out from under the navigation bar - #22
Merged
Conversation
The Model field is free text because model IDs are not ours to enumerate, so it accepts anything — including a sentence typed into a window that merely happened to have the field focused. Nothing notices until the end of the next dictation, where it comes back as a 404 that reads exactly like a bad key. Checks shape and never existence: no client can know which models an account can reach, but every client can know a model ID has no spaces in it and no CJK. What passes is still sent to the provider, which stays the authority on whether the model is real. Mirrored word for word across Swift, C# and Kotlin, with the tests duplicated in each language rather than shared — a fixture would be read by whichever platform remembered to read it. The accepted set is taken from IDs these providers really serve: google/gemini-3.6-flash, qwen2.5:7b-instruct-q4_K_M, ft:gpt-4o:acme:tone:1. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0199EDkXZ1NZzx1dk9KYTpmG
Two ways the caret could start in a text field of the General panel, both of them in the field that saves as you type: Left to itself AppKit hands a new window's first responder to its first text field, which here is Model. `defaultFocus` pins it to nothing instead, at `.userInitiated` priority — "no default" is a preference, and the automatic evaluation would otherwise read it as no preference at all and fall back to that same first field. And the window is cached rather than released on close, which is what keeps the sidebar where it was left — AppKit restores the first responder along with it. So one click in Model, once, made every later ⌘, put the caret back there. Reopening a settings window is not typing into it. A missing key still takes the caret, from the task on the key field: that placement has a reason and this one never had one. The three model fields — primary, rewrite, fallback — now refuse to store a value that could not be a model ID, and say why underneath. What was typed stays in the field; the previous value stays in effect and keeps running dictations, which is what "In use" now reports rather than the unsaved text. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0199EDkXZ1NZzx1dk9KYTpmG
Same guard and the same sentence as the macOS panel. iOS focuses nothing when Settings opens — SwiftUI on iOS has no equivalent of the AppKit first-responder grab — so this half is only the validation: the primary and fallback model fields save as you type, and a stray keystroke should not replace a working configuration before anyone can read why. The note goes directly under the field it is about, like the endpoint note, since this Section has no separate block of explanations the way macOS does. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0199EDkXZ1NZzx1dk9KYTpmG
Shown as the field is typed in rather than only when Save is pressed — the check is about the shape of what is in the box, so it can answer immediately, and a field that objects only after you commit it makes you find the mistake twice. Save is refused outright, because the rest of the form saves around it: without this the screen would say "Saved" while the one field just edited was the one thing that had not been, and the previous model would go on running dictations with nothing on screen saying so. No focus change was needed here. `screenScaffold` already takes the initial focus itself so the topmost EditText does not — it was added to stop a ScrollView opening halfway down its own screen, and it happens to close this door too. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0199EDkXZ1NZzx1dk9KYTpmG
WinForms gives the focus to the first control in tab order when a form is shown, which on the General tab is the Service dropdown with Model directly behind it. Both take keystrokes aimed at neither: a DropDownList jumps its selection to whatever letter is typed at it, and the Model box keeps the characters. Opening a settings window is not typing into it, so nothing starts focused; Tab still reaches every control in the same order. The Model box says what is wrong with it as it is typed, and Save is refused while it holds something that could not be a model ID. Refused before anything else is written, because this method saves the whole form: otherwise the window would report "Saved." while the one field just edited was the one thing that had not been. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0199EDkXZ1NZzx1dk9KYTpmG
PARITY gains the rule itself, since it is now a claim all four clients make in the same words. MANUAL-CHECKS gains the focus check as its eighth entry, and it belongs there rather than in a test for a concrete reason: a SwiftUI window reports zero children to System Events, so nothing outside the app can ask which field holds the caret. What can be checked from outside is the consequence — open the panel, type, and see whether anything moved — including the reopened case, which is the half AppKit had to be told about directly. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0199EDkXZ1NZzx1dk9KYTpmG
… bar Settings, the mode chip, Return and Backspace were 38dp tall and sat at the bottom edge of a keyboard window that, from API 35, is laid out behind the navigation bar. A three-button bar covered half of each of them. The inset listener that was already there is not enough by itself. A view added to a window that is already laid out is not guaranteed a dispatch, and a listener that never ran leaves the padding at zero — which looks exactly like having no listener at all. Three things together: - the bar asks for a dispatch on every show, which also catches a rotation between two of them; - KeyboardInsets takes the display's own navigation inset as a floor, but only while the window is edge to edge. Below API 35 the window manager puts the keyboard above the bar, so honouring a display measurement there would add a dead strip to every older phone. That distinction is the whole reason the arithmetic is a separate object with tests: there is no emulator assertion for "the user cannot press this"; - the row is 48dp, Android's minimum touch target, and the keys keep 10dp of trailing room whatever the bars do. The mode chip stays visually smaller than the talk button it sits beside — that was deliberate — and gains the row's height as its touch target through an inset background rather than a taller fill. Compiles and unit-tests locally; untested on a device.
This was referenced Aug 30, 2026
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
What this changes, and why
Reported from a phone: the keyboard's bottom row is half-covered by the system's navigation
buttons and very hard to hit. Two causes, and only fixing one of them would have left the report
half-true.
The row was too short. 38dp is iOS's number, taken deliberately so the two keyboards feel like
one product. It is the right number there, because iOS keeps the bottom of the keyboard clear for
the app. Android does not: from API 35 an
InputMethodServicewindow is laid out behind thenavigation bar. 48dp is Android's minimum touch target, and the bottom row of a keyboard is the
one place in this product where that minimum is not advice.
The existing inset listener could silently never run.
ViewCompat.setOnApplyWindowInsetsListenerwas already there, and it is the documented fix — but a listener installed on a view that is added
to a window which is already laid out is not guaranteed a dispatch, and one that never ran leaves
the padding at zero, which is indistinguishable from having no listener. So the bar now asks for a
dispatch on every show (which also catches a rotation between two of them) and treats the display's
own navigation inset as a floor.
That floor is applied only while the window is edge to edge, which is what
KeyboardInsetsexists to decide. Below API 35 the window manager places the keyboard above the navigation bar and
reports no inset; adding the display's measurement there would put a strip of dead keyboard on
every older phone — the exact opposite bug. There is no emulator assertion for "the user cannot
press this", so that arithmetic is a plain object with a test for each case, including the two
failure directions.
The mode chip keeps the smaller pill it was deliberately given (a second saturated block beside the
talk button competes with it) and gains the row's height as its touch target, through an inset
background rather than a taller fill.
Platforms
./gradlew testandassembleDebugpass locally. Untested on a device — no emulator or phoneattached here, and the thing this fixes is by definition invisible without one. Worth a look on a
phone with three-button navigation, and on one running Android 14 or earlier to confirm the bar has
not grown a gap.
Checks
swift test,dotnet testand./gradlew testpass