Conversation
Fixes #232
Fixes #115
Added IBAN and PayPal option of the current maintainer
feat: add Alt+Enter layout switch option
Fixes #241
fix: consume Shift on Enter
AdbClient opened a plain Socket with no connect timeout and never set soTimeout, so a read could block forever. A phone that has not authorised our key does not refuse the connection, it simply stops answering — and that read runs while holding the broker lock, so a single unauthorised connect would deadlock every later privileged call rather than failing one. A coroutine timeout cannot rescue this: a blocking socket read ignores cancellation, and the thread would stay parked on the lock. Both phases are bounded instead.
isPaired() answers "is a key stored", which has no bearing on whether anything works. The key is written the moment a pairing is ATTEMPTED and survives Wireless debugging being turned off, so it reports a working setup in two cases where nothing can connect at all. verify() connects and runs a command, and says which of OK, NOT_PAIRED, WIRELESS_DEBUGGING_OFF, NO_SERVICE or REJECTED applies. REJECTED is the state no stored value can reveal: a key is held and the phone is advertising, but the connection is refused because the phone never accepted that key. BrokerStatusMonitor holds the single verdict every surface reads. Screens answering this question independently is what let the home tile read "needs pairing" while the toolbox on the next screen said the tools could reach the system, at the same moment. Concurrent callers await the in-flight check, a verdict is reused briefly, and invalidate() drops it when it stops being meaningful.
Constructing AdbKey generates and stores a key when none is held, and that happens before the pairing code is checked. A wrong code threw, the failure notification appeared, and the key stayed — so from the first mistyped digit the app was permanently certain it was paired against a key the phone had never accepted, with the backlight, the overlay grant, the trackpad and the package tools all gated on it, silently. A key minted by an attempt is now discarded when that attempt fails. One that predates the attempt is left alone: it may be backing a pairing that still works. The cached broker verdict is dropped either way, since pairing is exactly when it stops being valid. Whether a key existed is read through isPaired rather than keyStore.get(), which base64-decodes and would throw on a corrupt value outside the try block.
Every surface that told the user the toolbox was ready read "is a key stored". The setup card claimed the tools could reach the system with Wireless debugging switched off; the home tile raised no attention badge; the bloat remover offered actions that could only fail after the user committed to them; the trackpad's fallback to the permission screen never ran, because a stale key looked exactly like a working broker, so it enabled and stayed dead. All of them now read the shared verified status, and say which of the failures applies. Wireless debugging being off points at the toggle rather than telling the user to pair again — the pairing is not what is wrong there. Forget pairing and Re-pair exist because there was no way out of a bad pairing but clearing app data, which takes every setting with it. The backlight additionally asks the phone whether it is actually holding the always-on value: "configured" is a one-way latch that cannot notice a system update resetting it, which is how the toggle reads on while the backlight times out.
The switch read a stored flag recording that a binding had once been asked for. The slot it writes lives in Settings.System, where a system update or another app can take it back without telling us, so the switch kept reading on while the orange key did nothing. It now reads the slot on open and corrects itself. VendorSideKeyManager.isAssistantBound already existed for this and nothing called it; reading the value needs no permission, unlike writing it.
Reporting "Enabled: No" with no way to act on it left the user to find the setting themselves. Both IME rows are now tappable when they are the thing that is wrong: one opens Android's keyboard list, the other the picker — or the enable screen first, since the picker cannot offer a keyboard that is not enabled yet. The screen also re-reads on resume. Computed once at open, it showed the state the user had just left rather than the one they had just chosen, which is the moment the screen exists for.
The check has failed on every push since 2026-08-29, so 2.0.1, 2.0.2 and 2.0.3 all shipped with a red build and nothing else it guards — unit tests, lint, the R8 sideload build — has run since. All three matches are KDoc explaining the 2.0 rename in SettingsBaseline and PackageReplacedReceiver. What the check exists to catch is a half-landed rename leaving a LIVE identifier behind; the migration code cannot be explained without naming what it migrates from, and buying each comment off with another entry in the filename filter just defers the next failure. Comment lines are exempt instead. Verified both ways: the tree passes, and a live identifier planted on a code line is still caught.
remember returning Unit is always a mistake: it means remember is being used to run a side effect rather than to cache a value, and Compose gives no guarantee about when or how often it runs. Seeding the persisted verdict belongs in the LaunchedEffect that refreshes it. Shipped in 2.0.3 because CI's lint step had not run since 2026-08-29, blocked behind the upstream-identifier check.
…g diagnosed Layout, all visible on a Titan at the shipped font size: Record/Clear/View/Share sat in a horizontally scrolling row beside a fixed 140dp column of chips, so Share was clipped mid-word and "incl. autocorrections" broke in the middle of a word. Both rows wrap at full width now and the chips size to their labels. The "Last Keyboard Event" heading was placed after the values it labels, so it rendered underneath them. "Started at: " lost its trailing space, which Android strips from string resources unless they are quoted. The export could not answer the question it was added for. There is one IME context slot, overwritten by whatever field has focus, and the Diagnostics screen has a text field — so opening Diagnostics to export a report replaced the context of the app being diagnosed with PhysiBoard's own. A report from a user debugging Facebook Messenger came back describing brobata.physiboard with ime_options=0x0. The last context from an app other than this one is now kept separately and exported as external_*. Facebook Messenger is added to the picker's favourites. It is in none of the Enter-behaviour package sets, so resolveBehavior returns null and Enter is left alone entirely — the send path never runs, which is why Enter does nothing there rather than failing. It is deliberately NOT added to the tested set: no preset has been verified for it, and that list means what it says.
The chooser existed from the start, behind a bare "+" in the app bar that nobody found, so an app missing from the list read as unsupported rather than not added yet. There is a visible "Add app" row at the end of the list now, where someone hunting for their missing app is already looking. The dialog itself could not be used either: three dropdowns above the list left it roughly one row tall to scroll several hundred apps through. Choosing now comes first with the height and a search box that matches names and package ids, and the settings — whose defaults are usually right — come after, with Back to return to the list.
The Status Bar Theme page wrote status_bar_slots_left/right; the bar read pastierina_status_bar_slots_left/right. Those are the retired namespace: the 2.0 migration renames them to the former and its dropped prefixes delete the rest, so the key the bar asked for was guaranteed absent and its getter handed back a fallback instead. Every assignment made on that page did nothing, and the bar sat on LANGUAGE | HAMBURGER no matter what was chosen. The first-run preset, which writes the canonical keys, was inert for the same reason. Read the canonical keys. The dead getEnabledButtons(context) overload already did exactly that and had no callers, so the two collapse into one. The pastierina slot accessors go with them. They can only ever name a key the migration deletes, and leaving them in the file is an invitation to wire a reader back to one. Slot changes now re-render the strip live, alongside the other status bar prefs, rather than waiting for the next IME rebuild. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TXgeqURNZb2mGDNjpSsCTS
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TXgeqURNZb2mGDNjpSsCTS
The ring turns the screen on, and the vendor lights the keyboard on every screen-on -- so a notification at night lit the whole keyboard to show a ring that exists precisely so nothing else has to. With Smart Backlight on (keyboard_brightness_timeout = -1) it then never went off again. The switch is the vendor's own Settings.Global agui_keyboard_background_light, the row the Quick Settings tile already toggles. Writing it needs WRITE_SECURE_SETTINGS, which the broker now takes once with a pm grant alongside the grants it already makes; every write after that is an ordinary in-process settings write. The backlight timeout is the wrong lever for this -- it is a vendor binder call, so every write is a broker round trip, and the keyboard has to go dark in the moment a notification lands. It is switched off from the listener, before the launch, not from the ring: the vendor reads the row when the screen comes on, and by the time the activity exists the screen already has. The row outlives the app, so every path ends with the user's value restored: - the ring ends -- NotificationRingActivity.onDestroy, the one funnel that already covers expiry, a touch, a key, an unlock and the screen going off. - the ring never appears -- the system can decline a full-screen launch, so suppress() arms a timer past the launcher's own announcement timeout. - the process dies between the two -- the value to put back is committed to preferences BEFORE the switch is touched, and replayed at every process start. A keyboard the user had already turned off is left alone, and the Quick Settings tile no longer captures a ring-suppressed 0 as the user's own value, which would have had Reset to stock hand them a dead keyboard. Without the permission the feature is inert and the settings row says so, rather than offering a switch that quietly does nothing.
Correction quality is not observable from a diff, so every constant in the ranking and every clause in the commit rule has been chosen by judgement with no way to tell whether a change helped. That is how a keyboard gets quietly worse: each change is locally reasonable and the aggregate is unmeasured. AutocorrectEval replays a corpus of (typed, intended) pairs through the real SymSpell, the real SuggestionEngine ranking and the real commit decision, and reports fixed / missed / wrong / clobbered, plus a false-correction rate, recall and precision. The headline number is the false-correction rate, and the corpus is deliberately a third controls - correctly typed words that must survive untouched. Overruling a word the user typed correctly is the expensive error; a missed typo costs almost nothing. A change that lifts recall while lifting the false-correction rate is a regression however good it looks. To make that possible the commit rule is extracted from handleBoundary into AutoReplaceController.shouldAutoReplace(ReplaceFacts), and the two constants it shares with the rejection-reason telemetry are hoisted beside it. The harness scores the shipped predicate; a copy of the rule kept beside it would drift within a release, and measuring a rule the keyboard does not use is worse than not measuring. The evaluation dictionary is backed by the real SymSpell rather than FakeDictionaryRepository, whose brute-force Levenshtein has no transpositions and none of the delete-neighbourhood behaviour that ships. First numbers, 74 cases: recall 0.825 at the shipped distance 2, 0.775 at distance 1, no false corrections either way. That last figure says more about the 105-word evaluation vocabulary than about the keyboard - clobbering needs a crowded dictionary to appear - and getting real data into the corpus is the next step, recorded in the plan. Plan: docs/plans/autocorrect-rework.md
The 105-word evaluation vocabulary could not reproduce the problem being investigated: clobbering a correctly typed word needs a crowded dictionary before it can happen at all, so the false-correction rate came out at zero and said nothing. Running the same corpus through the real AndroidDictionaryRepository and the shipped 48k en_base.dict produces the failures instead of hiding them. Opt-in, because a 13 MB CBOR decode has no business in the normal test loop: ./gradlew :app:testDebugUnitTest -Pphysiboard.eval.realDictionary=true 74 cases, shipped configuration: recall 0.775, false-correction rate 0.041. The report now separates coverage holes from bad decisions by asking whether the typed word is in the dictionary at all. Of the three damaging outcomes, `salve -> slave` is a word the dictionary has never heard of - the engine had no way to know it was real, and no cost model or threshold would have saved it. The other two are one shape: a real word beating the intended one at equal or lower edit distance, with nothing in the scorer able to prefer the right one. definately -> defiantly wierd -> wired Both `defiantly` and `wired` are perfectly good words. Uniform-cost retrieval followed by boolean gates cannot tell them apart from the intended words, which is the case the keyboard-aware cost model, the confidence threshold and the context prior are meant to handle. Two side findings, both recorded in the plan. Keyboard proximity ranking earns its keep - turning it off raises the false-correction rate to 0.054 and fixes nothing extra. And turning the distance dial down does not address clobbering, because the clobber is a distance-1 transposition; the dial is a blunt instrument doing a job a confidence threshold does better. Plan: docs/plans/autocorrect-rework.md
… some are "If the user types a real word it should not be corrected" is already what the commit predicate says - isKnownWord blocks a replacement, with narrow exceptions for case and accent repair. This asserts it rather than assuming it, and the invariant holds without exception: across every configuration, no word present in the dictionary was overruled. Which means the rule was never the problem. The only way a real word gets overruled is if the dictionary has never heard of it, so the sweep measures that instead. 116 ordinary English words - the sort anyone writes - against the shipped en_base.dict: missing from the dictionary 14 of 116 (12%) overruled 4 salve -> slave lithe -> litre dowdy -> dowry flout -> flour Twelve percent of ordinary vocabulary is absent from a 48k-key dictionary, and one in four of those absences becomes a wrong correction as soon as the word sits near a commoner one. Nothing in the scorer can prevent this: to the engine, `flout` is indistinguishable from a typo for `flour`, because `flout` does not exist. Two ratchets, so both halves are held. Known words overruled must stay at zero - that one is an invariant and is not negotiable. Real words overruled is at the measured 4 and should be lowered as coverage improves; every word added to the dictionary moves it. The consequence for the plan is that the dictionary work is no longer last. Two scoring failures against four coverage failures in this corpus, and it is the coverage failures that overrule a user who did nothing wrong. Raising the truncation cutoff in scripts/truncate_dict.py is the cheapest first move and carries no engine risk; the 27.4 MB of dead _base.json already in the APK pays for the larger dictionary several times over. Plan: docs/plans/autocorrect-rework.md
… costs The repository has never owned the step that decides which words the keyboard knows. en_base.json arrived from outside with frequencies already quantised, so the highest-leverage decision in autocorrect was made where nobody here could see or re-run it. Raising the truncation cutoff, which the plan proposed, turns out to be impossible: the source JSON is exactly 50,000 entries and contains none of the fourteen missing words. The truncation happened upstream. It would not have helped anyway - the rarest shipped word sits at Zipf 1.68, rarer than everything found missing, so depth was never the problem. The corpus is. Measured against wordfreq, 20% of the shipped 50k fall outside wordfreq's top 50k, and 21.7% of wordfreq's top 50k are absent. It carries passerine, officership, subchannel, kbit, simulcasting and USAAF while missing vex, ember, loathe, flout and salve. That is an encyclopedic word list, which is the wrong shape for a phone: it spends its budget on terms nobody types and omits ordinary words, which the engine then treats as typos. Ranking by frequency alone makes it worse, and the harness caught it before any of this could ship. Real-text frequency data contains real misspellings - alot, teh, thier, untill, definately, seperate, recieve and wierd are all in wordfreq's top 80k - and once a typo is a word the dictionary knows, isKnownWord protects it forever. Recall fell from 0.775 to 0.250 as the list grew. "Never correct a real word" plus a raw-frequency list equals "never correct anything". So membership and ranking come from different sources: a curated lexicon decides which words exist, wordfreq decides how common they are. list recall false-corr missing/116 real words overruled shipped 50k 0.775 0.041 14 4 lexicon 50k 0.700 0.014 2 1 lexicon 80k 0.650 0.014 0 0 The lexicon-filtered 80k list satisfies the rule absolutely - no real word overruled, no ordinary word missing - and cuts the false-correction rate by two thirds. It costs recall, and that trade is the right way round: a coverage failure cannot be recovered by any scoring change, while the lost recall is precisely what the cost model, the confidence threshold and the context prior are for. VocabularySweepTest scores a candidate list from a plain TSV before anything is built into a 13 MB .dict, because adding vocabulary fixes missing words and simultaneously gives every typo more neighbours, and neither effect is visible by inspection. No dictionary is rebuilt or shipped here. That changes every install and is the maintainer's call; what lands is the generator, the measurements and the instrument. Plan: docs/plans/autocorrect-rework.md
… call The engine has always computed a score and then used it only to order the three visible suggestions. The commit decision was eight booleans, so "how sure are we" was not a question the keyboard could ask, and it shows in the failures: a typo retrieves two real candidates, the scorer separates them by almost nothing, and a boolean gate has no way to express "too close to call - offer it, do not impose it". Confidence is the relative margin between the top candidate and the runner-up, not the top score. Absolute scores are an unbounded additive pile whose scale drifts with word length, frequency and which bonuses applied, so no fixed cutoff on them means the same thing twice. A margin is scale-free and asks the only question worth asking before overwriting what somebody typed: did the winner win clearly. handleBoundary now asks for two candidates rather than one, and the rejection telemetry gained too_close_to_call so a marginal call is distinguishable from a shape rejection in an export. The default of 0.10 is measured, not chosen. Against the shipped dictionary it takes real words overruled from 4 to 1 and the false-correction rate from 0.041 to 0.014, costing five points of recall. Past 0.20 recall collapses and buys nothing further. Two things the sweep disproved, both recorded in the plan. It does not recover the recall the dictionary work spent, which was the premise for holding that work back. Against a lexicon-filtered word list the threshold buys nothing - overruling is already zero at every setting - and only costs recall. The two mechanisms fix the same failure by different means, so applying both pays twice for one thing. And it cannot fix a confidently wrong correction. definately -> defiantly survives every threshold on every word list, because the scorer does not narrowly prefer defiantly, it prefers it clearly. A margin test filters close calls, not wrong convictions; that one needs the keyboard-aware cost model or the context prior. Ratchets re-based: false-correction rate tightened 0.041 -> 0.014, recall deliberately spent 0.775 -> 0.725. Plan: docs/plans/autocorrect-rework.md
…er corrected
The rule is absolute - a word the user spelled correctly must not be overwritten - and of the
two routes measured, only this one reaches zero. So the dictionary is rebuilt and the
confidence threshold drops from 0.10 to 0.02, because both mechanisms fix the same failure and
applying both at strength pays twice for one thing.
en_base.json is regenerated at 80,000 entries: wordfreq for ranking, the pyspellchecker lexicon
for membership. Neither works alone. Frequency data drawn from real text contains real
misspellings, and a list that knows `alot` and `teh` can never correct them; a lexicon alone
has no idea which words are common.
before after
real words overruled 4 0
ordinary words missing 14 0 (of 116 checked)
false-correction rate 0.041 0.014
recall 0.775 0.650
Recall is the debt this takes on knowingly. A coverage failure cannot be recovered by any
scoring change, while this recall is exactly what the keyboard-aware cost model and the context
prior are for, so the trade only runs this way round. MAX_OVERRULED_REAL_WORDS is now zero and
is an invariant rather than a ratchet.
The APK gets smaller despite a 7.3 MB larger dictionary. The legacy `_base.json` word lists are
build inputs that nothing reads at runtime, and the attempt to exclude them lived in
packaging.resources, which filters Java resources and not Android assets - so 2.0.6 shipped 13
of them, 27.4 MB. androidResources.ignoreAssetsPatterns is the switch that applies to assets.
2.0.6 release 50.2 MB
this build 48.5 MB
Still unfixed: definately -> defiantly survives every word list and every threshold, because
the scorer prefers defiantly clearly rather than narrowly. That one needs the cost model or the
bigram prior.
Only English has been rebuilt. The other eleven bundled languages came from the same upstream
process and very likely carry the same problem.
Plan: docs/plans/autocorrect-rework.md
Google's engines close the microphone about two seconds after any sound and report "no match" if no words came through. A user who presses the trigger, draws breath and then speaks got "No text recognized" for the breath: on a Titan 2 in Teams the engine had closed the microphone ten milliseconds before speech began. A quiet error before any text has arrived now restarts listening, for up to ten seconds and five restarts, instead of ending the session with a toast. A busy engine is retried after a short delay for the same reason. Partial results were inserted with the cursor placed before them, and the final result normally moved it after. Google's system engine sends the whole utterance as its last partial followed by an empty final, so nothing moved the cursor and the next dictation went in ahead of the previous one. Partials now place the cursor after the words, and an empty final finishes the utterance from the last partial with the same spacing and capitalisation a final gets. A quiet error after a partial does the same rather than clearing the words and complaining. A busy, network, server or language error inside the first second of a segmented session was taken as the engine refusing segmented sessions and switched them off for the rest of the process. Only a client error or an unknown code counts as a refusal now. Nothing stopped a session when its text field went away, so the mic stayed live against a dead connection until the silence timer noticed. The session now ends when no editor replaces the one it started in, or when another app takes the editor.
…r it Teams places its compose box itself from the keyboard inset and only moves it when the keyboard animates or its window regains focus. With a hardware keyboard the bar is already on screen when the box is tapped: Teams resets its layout, asks for the keyboard, the keyboard declines because there is nothing more to show, no inset changes, and the box stays hidden under the bar until something else changes window focus. That is why it looked intermittent. For apps on a new per-app list the bar now hides and re-shows itself when the app asks for the keyboard, which is the animation those apps wait for. The hide has to reach the app as its own event: a re-show in the same frame is folded into it by the window manager and the app sees nothing, so the bar is held down for a fifth of a second and the keyboard's own re-show attempts are refused meanwhile. The window-hidden callback during the dip does not reset modifiers or suggestion context. The list lives under Keyboard, "Text box under the bar", seeded with Teams. The per-app switch list is shared with Exact typing.
The badge window was added through the keyboard's own context, which is typed as an input method, so the system logged a window-type mismatch on every caret update. The overlay now comes from a window context of its own type, falling back to the service context where one cannot be made.
…aviour Upstream removed the Modifiers screen (ef222dc) and later replaced the paged tutorial with the onboarding screen, leaving long_press_modifier and the long-press threshold with no reachable UI. Add a Keyboard > "Long Press behaviour" row (after Enter key behaviour) that opens a new screen with two controls: - Long Press: slider, 50-1000 ms, default 300 ms - Long Press Modifier: Alt (Punctuation) / Shift (Capitalization) / Variations (Diacritics) / Sym (Symbols); legacy sym_* values display as Sym Both are found by settings search via a new LONG_PRESS_BEHAVIOR target. Rows live in LongPressSettingsRows.kt (shared with the unreachable KeyboardTimingSettingsScreen, which keeps its slider). AltSymManager, InputEventRouter and StatusBarController already read the preference live. Reviewed by six independent models (GPT-6 Astra, Gemini 3.1 Pro, Claude Opus 5 via Copilot, Grok 4.6, DeepSeek V4 Pro, Claude Sonnet 5 via Cursor). Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01EW1AVLdCJH8EZDaGRqbsfw
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.
Fixes #9
Summary
Adds Keyboard → Long Press behaviour, a small screen under Enter key behaviour with two rows:
sym_symbols/sym_emojivalues display as SymBoth rows are reachable from settings search (new
LONG_PRESS_BEHAVIORtarget). Defaults are untouched, so nothing changes for anyone who never opens the screen.Why
The
long_press_modifierpreference and the threshold still exist and are still read live by the input path, but ef222dc removed the Modifiers screen and the paged tutorial is no longer reachable, so there was no UI left to set them. Users coming from BlackBerry 10 keyboards expect a held letter to type its capital; this restores a way to choose that.Implementation notes
LongPressSettingsRows.kt(LongPressThresholdRow,LongPressModifierRow, and a statefulLongPressSettingsRows()with aSharedPreferenceschange listener so external writes, e.g. a backup restore, refresh the UI).KeyboardTimingSettingsScreenkeeps its slider through the shared composable.values/strings.xmlonly; other locales fall back to English.PHYSIBOARD_CHANGES.mdhas an Unreleased entry.:app:testDebugUnitTest :app:lintDebugpass; the change was verified on a Titan 2 Elite (debug build), including search.Review
The diff was independently reviewed before submission by six models (GPT-6 Astra via codex, Gemini 3.1 Pro via Antigravity, Claude Opus 5 via Copilot, Grok 4.6, DeepSeek V4 Pro via OpenCode, Claude Sonnet 5 via Cursor); their findings (state hoisting, ripple/semantics on the clickable Surface, selected-item semantics, min-height and ellipsis for long labels, static option list) are folded in.
🤖 Generated with Claude Code Fable 5.1
https://claude.ai/code/session_01EW1AVLdCJH8EZDaGRqbsfw