Skip to content

fix(android): rebind the test IME when its commit went to a stale input session - #3061

Open
okwasniewski wants to merge 9 commits into
callstack:mainfrom
okwasniewski:oskar/ime-rebinds-stale-input-session
Open

okwasniewski wants to merge 9 commits into
callstack:mainfrom
okwasniewski:oskar/ime-rebinds-stale-input-session

Conversation

@okwasniewski

@okwasniewski okwasniewski commented Sep 29, 2026 •

Copy link
Copy Markdown
Contributor

Fixes #3052.

Summary

On an API 36 emulator under load, fill into a freshly mounted, autofocused React Native TextInput sometimes fails with Android fill verification failed. The field keeps showing its hint (hintShowing: true). The test IME commits (commitText length=N), but the app drops the text:

W/RemoteInputConnectionImpl: Session id mismatch header.sessionId: 0 currentSessionId: 1 while calling ...commitText

The IME is left holding an input session the app has already replaced, so the app rejects everything it sends: beginBatchEdit, performContextMenuAction, commitText, endBatchEdit. The fill retry re-taps the field, but a tap on a field that already has focus starts no new session, so the retry failed the same way 1.6 s later. In logcat, every failure was one such stale episode, covering both attempts.

When none of a helper commit reached the field (it shows its hint, or the value it held before the fill), the retry now rebinds the IME with ime disable, ime enable and ime set on the helper service before re-focusing. The rebind recreates the IME service, which starts a fresh session with the focused field.

  • The rebind reads the selected IME back, as activation and restore do. If another IME is selected, the device leaves the helper route (android_test_ime_rebind_failed), so the next text entry goes through the checked activation path, and the fill stops retrying.
  • A field back on its hint never counts as the unconfirmed "app formatting" soft success, since nothing that reformats text empties a field.

Touched: ime-helper.ts (rebindAndroidImeHelper), text-input.ts (the retry), fill-verification.ts (the soft-success guard), and 1 test file.

Validation

  • e2e mobile benchmark, sequential onboarding echoes the exact values repeated on a Pixel 7 API 36 emulator with 8 CPU-stress processes, logcat captured:

    Build Runs Failed Stale sessions seen
    main 60 3 not captured
    main 40 2 2, one per failure
    this branch 60 0 1, recovered
    this branch 60 0 3, all recovered
    this branch, final (checked rebind) 60 0 2, all recovered

    On this branch, the recovery shows in logcat as the session mismatch, then the IME's onCreate (the rebind), then clearText and commitText with no further mismatch.

  • text-input-test-ime.test.ts:

    • a commit the app drops while the field shows its hint is retried after ime disable, enable, set, in that order and before the retry's tap, and the text lands (fails on main);
    • a pre-filled field whose commit was dropped after its clear is rebound and filled, not reported unconfirmed;
    • a rebind that leaves another IME selected sends no second commit and takes the helper route down;
    • the existing not-yet-bound retry test (field keeps its old value) now expects the rebind.

    All fail on main.

  • pnpm check:affected --run: passed. pnpm test:coverage:ci: passed. Changed-line gate: passed.

…ut session

Android can leave the test IME holding an input session the app has
already replaced: the app then drops every commit ('Session id
mismatch header.sessionId: 0 currentSessionId: 1'), and re-tapping a
field that already has focus starts no new session, so the fill retry
failed the same way. When a helper commit left the field showing its
hint, the retry now rebinds the IME (ime disable, enable, set) before
re-focusing, which starts a fresh session with the focused field.
Copilot AI balanced review requested due to automatic review settings September 29, 2026 15:43

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.

Copilot was unable to review this pull request because the user who requested the review has reached their quota limit.

@cubic-dev-ai cubic-dev-ai Bot 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.

All reported issues were addressed across 3 files

Reply with feedback, questions, or to request a fix.

Fix all with cubic | Re-trigger cubic

Comment thread packages/platform-android/src/text-input.ts Outdated
Comment thread packages/platform-android/src/ime-helper.ts
Comment thread packages/platform-android/src/ime-helper.ts
Comment thread packages/platform-android/src/ime-helper.ts
A commit none of which reached the field (hint showing, or the value
the field held before) triggers the rebind, so a pre-filled field's
dropped clear-and-commit recovers too. A field back on its hint is no
app formatting, so it never ends the retry as unconfirmed evidence.
The rebind reads the selected IME back; if another IME is selected the
device leaves the helper route and the fill stops retrying.
Copilot AI review requested due to automatic review settings September 29, 2026 16:01

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.

Copilot was unable to review this pull request because the user who requested the review has reached their quota limit.

Copilot AI review requested due to automatic review settings September 29, 2026 16:04

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.

Copilot was unable to review this pull request because the user who requested the review has reached their quota limit.

@thymikee

Copy link
Copy Markdown
Member

I found one problem in 153e257 that should be fixed before merge. CI is green, with all 13 checks passing, and I know of no conflicts.

The rebind failure path in rebindAndroidImeHelperChecked can leave the device on the test IME or without its original keyboard, and it leaves no recovery marker. It runs ime disable/enable/set and deletes the device from activeTestImeDevices. It does this outside withAndroidTestImeRecoveryLock and without settling the persisted previous-IME record or the recovery marker. First case: readAndroidDefaultInputMethod returns '' when settings get times out or fails, which is plausible under the load this PR targets, while the helper is still selected. Ownership is dropped anyway, so at session close restoreAndroidTestIme sees no membership and returns 'no-record'. That counts as complete, the marker is cleared, and startup recovery has nothing to act on. Second case: if ime disable moved the device to a fallback IME and set then failed, the close-time restore is skipped. The original IME is never restored, and the next open overwrites it with the fallback. Main never disabled the helper during a fill, so the rebind caused this. The rule is that activeTestImeDevices membership may only be removed by a path that also settles the restore record and marker by read-back, under the recovery lock. Today the rollback in ime-activation.ts and restoreAndroidTestIme are the only owners. When the read-back is empty or unknown, please keep ownership. When it positively shows another IME, please route through the ime-restore owner, which sets the previous IME and clears the record and marker only on confirmation. Please do not delete the key directly from text-input.ts. A test where the read-back returns exitCode 1 and asserts that the marker and ownership survive would prove it.

Not blocking, and you can take or leave these: the rebind-failed test at text-input-test-ime.test.ts#L372 uses a bare assert.rejects, so any rejection passes and the marker and persisted record are never checked; and the new hintShowing guard in fill-verification.ts#L165 is shared with the adb-shell fill path, so a shell fill that ends on the hint now throws instead of returning unconfirmed, which is worth a note in the PR body or a shell-route test.

I did not rerun the emulator benchmark or the logcat sequence from the PR body, and the android_test_ime_rebind_failed branch was never exercised live. I also did not trace the claim that provider artifacts cannot carry a different serviceComponent. The second failure case depends on which IME Android picks after ime disable, and I did not check that on a device.

Before merge, the rebind failure branch must hand ownership back through the ime-restore owner, under the recovery lock and settled by read-back.

…the helper

The rebind failure path dropped the device from the owned set outside the
recovery lock and without settling the restore record or marker, so a
failed read-back (settings get timing out) made close-time restore report
no-record and strand the user on the helper. The rebind now lives with the
activation owner, never drops ownership, and marks the device displaced
instead: close-time restore then returns it to the recorded previous IME
even when Android fell back to another IME, and a later activation keeps
the recorded previous IME as the restore target.
Copilot AI balanced review requested due to automatic review settings September 30, 2026 10:54

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.

Copilot was unable to review this pull request because the user who requested the review has reached their quota limit.

@cubic-dev-ai cubic-dev-ai Bot 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.

1 issue found across 9 files (changes from recent commits).

Prompt for AI agents (unresolved issues)

Check if these issues are valid — if so, understand the root cause of each and fix them. When an issue isn't valid or won't be fixed in this PR, reply in its thread with the reason and then resolve the thread. If appropriate, use sub-agents to investigate and fix each issue separately.


<file name="packages/platform-android/src/ime-state.ts">

<violation number="1" location="packages/platform-android/src/ime-state.ts:11">
P3: This comment's grammar obscures the ownership invariant; rewrite it to state that the rebind disabled the helper and Android selected a fallback IME.</violation>
</file>

Tip: Review your code locally with the cubic CLI to iterate faster.

Fix all with cubic | Re-trigger cubic

Comment thread packages/platform-android/src/ime-activation.ts Outdated
Comment thread packages/platform-android/src/ime-restore.ts Outdated
Comment thread packages/platform-android/src/ime-activation.ts
Comment on lines +11 to +12
// Owned devices whose helper a rebind disabled without confirming it selected again. The IME
// Android fell back to is not the user's choice, so it must not become the restore target.

@cubic-dev-ai cubic-dev-ai Bot Sep 30, 2026 •

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.

P3: This comment's grammar obscures the ownership invariant; rewrite it to state that the rebind disabled the helper and Android selected a fallback IME.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. When an issue isn't valid or won't be fixed in this PR, reply in its thread with the reason and then resolve the thread. At packages/platform-android/src/ime-state.ts, line 11:

<comment>This comment's grammar obscures the ownership invariant; rewrite it to state that the rebind disabled the helper and Android selected a fallback IME.</comment>

<file context>
@@ -8,6 +8,10 @@ import { getAndroidImeHelperDeviceKey } from './ime-helper.ts';
 // route text entry through the broadcast channel.
 export const activeTestImeDevices = new Set<string>();
 
+// Owned devices whose helper a rebind disabled without confirming it selected again. The IME
+// Android fell back to is not the user's choice, so it must not become the restore target.
+export const rebindDisplacedTestImeDevices = new Set<string>();
</file context>
Suggested change
// Owned devices whose helper a rebind disabled without confirming it selected again. The IME
// Android fell back to is not the user's choice, so it must not become the restore target.
// Owned devices whose helper was disabled by a rebind without confirming it was selected again. The
// fallback IME Android selected is not the user's choice, so it must not become the restore target.
Fix with cubic

The rebind now runs under the owner's recovery lock, found through the
ownership entry that activation records with its state dir, so a
close-time restore can no longer interleave with it. An on-device record
is written before the helper is disabled and cleared only once the helper
reads back selected; restore and activation read that record instead of
a process-lived set, so startup recovery after a crash also returns the
user's IME. A rebind whose read-back throws counts as unconfirmed, and
text entry rebinds an unconfirmed helper before any broadcast or fails
with android_test_ime_rebind_unconfirmed.
Copilot AI balanced review requested due to automatic review settings September 30, 2026 11:16

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.

Copilot was unable to review this pull request because the user who requested the review has reached their quota limit.

The rebind's catch-all turned request cancellation into an unconfirmed
rebind. A canceled request now rejects as canceled, keyed on the typed
error; the device record written before the rebind stays set, so restore
still returns the user's IME.
Copilot AI balanced review requested due to automatic review settings September 30, 2026 11:18

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.

Copilot was unable to review this pull request because the user who requested the review has reached their quota limit.

@cubic-dev-ai cubic-dev-ai Bot 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.

All reported issues were addressed across 9 files (changes from recent commits).

Tip: instead of fixing issues one by one fix them all with cubic

Re-trigger cubic

Comment thread packages/platform-android/src/ime-settings-record.ts Outdated
Comment thread packages/platform-android/src/ime-activation.ts Outdated
Comment thread packages/platform-android/src/ime-settings-record.ts
Comment thread packages/platform-android/src/ime-activation.ts
Comment thread packages/platform-android/src/ime-restore.test.ts
Comment thread packages/platform-android/src/text-input.ts Outdated
@thymikee

Copy link
Copy Markdown
Member

Thanks for the update. The stale-session rebind is not ready to merge on 580217b, because the new device route still has no live proof and the Coverage check fails.

The Coverage failure comes from this PR. restoreAndroidTestImeFor and activation now read agent_device_ime_helper_rebind_displaced. The fake in ime-lifecycle.test.ts throws on unknown settings keys, so 3 activation and startup-recovery tests fail. This is a stale test double, not a production regression. I read the CI log and the fake, and I did not rerun the tests locally. The other check is still running.

The delta changes the device-facing rebind route in ime-activation.ts. Every stale-session recovery now writes, reads back and deletes a secure setting around ime disable/enable/set. An unconfirmed rebind now keeps ownership and re-rebinds at admission (text-input.ts). The only live evidence in the PR body comes from an earlier head. Nothing yet shows that the settings round-trip keeps recovery working on a stressed emulator. Nothing shows that close and startup recovery return the user's keyboard after an unconfirmed rebind. Please rerun the stressed Pixel 7 API 36 benchmark on 580217b. Show logcat with a session-id mismatch, then android_test_ime_rebind, then the helper onCreate, then a successful commitText. Show that adb shell settings get secure agent_device_ime_helper_rebind_displaced returns null afterwards. Show that close leaves default_input_method on the original keyboard. I could not confirm that settings get secure for an unset key returns exit 0 with null over this adb transport, so that output matters.

Not blocking, and you can take or leave it: the PR body and your latest comment say a failed rebind takes the device off the helper route, but the head keeps ownership and fails the next entry with android_test_ime_rebind_unconfirmed, so the bullet could describe the rebindUnconfirmed and admission re-rebind behavior.

Next, please teach the fake in ime-lifecycle.test.ts the rebind_displaced key. Return 'null' by default and handle put and delete. Then post the live run on 580217b. I did not audit the new assertions in text-input-test-ime.test.ts and ime-restore.test.ts beyond reading the delta.

…r cleared

An unreadable record keeps the recorded restore target, an idempotent
activation keeps a rebind the record still marks, and a rebind stays
unconfirmed until its record is written and later cleared.
Copilot AI balanced review requested due to automatic review settings September 30, 2026 12:35

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.

Copilot was unable to review this pull request because the user who requested the review has reached their quota limit.

@thymikee

Copy link
Copy Markdown
Member

Thanks for the update. The 580217b findings are addressed in a258fbc, and the fake now has the new settings key, but one fail-open path remains in restore, and the live run is still missing.

Restore still fails open on an unreadable displacement record. Activation now treats an undefined read as unknown, but https://github.com/callstack/agent-device/blob/a258fbc/packages/platform-android/src/ime-restore.ts#L88 checks !== true. After an unconfirmed rebind, Android is on the fallback IME and the record is '1'. If the close-time settings get secure agent_device_ime_helper_rebind_displaced times out on a stressed device, the read returns undefined. Then restoreAndroidTestImeFor returns 'helper-not-active', isDeviceRecoveryComplete counts that as complete, and the recovery marker is cleared. Ownership is already deleted at that point. The user then stays on the fallback keyboard after close, and startup recovery has nothing to act on. The rule should be that an unreadable displacement record never counts as completed recovery at any reader, and only a positive false may lead to 'helper-not-active'. Please make restoreAndroidTestImeFor return a non-complete reason when the read is undefined and the current IME is not the helper, so the marker stays for a retry. Please also add a restore test where the record read exits 1, and assert that the marker and previous_ime survive.

The settings write, read-back and delete around ime disable/enable/set at https://github.com/callstack/agent-device/blob/a258fbc/packages/platform-android/src/ime-activation.ts#L275 changed again in this delta, and the only live run in the PR body predates 580217b. Please run the stressed Pixel 7 API 36 benchmark on a258fbc or later. Post logcat that shows the session-id mismatch, then android_test_ime_rebind, then the helper onCreate, then a successful commitText. Also post adb shell settings get secure agent_device_ime_helper_rebind_displaced returning null, and show that close leaves default_input_method on the original keyboard.

Not blocking: the comment at https://github.com/callstack/agent-device/blob/a258fbc/packages/platform-android/src/ime-activation.ts#L296 says the next entry "retries the clear", but rebindUnconfirmed sends it through confirmAndroidTestImeReboun​d, which runs a full disable/enable/set rebind, so you could reword it or retry only the clear when the helper already reads back as selected, and you can take or leave this.

I did not run the tests locally, and CI is still pending. Coverage and Integration run the platform-android IME tests this delta changes, so a failure there would be related to this PR. I also did not read every assertion of the new idempotent-activation test. Before merge, restore must keep the marker on an unreadable record, and the live run must be posted on the new head.

@cubic-dev-ai cubic-dev-ai Bot 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.

2 issues found across 7 files (changes from recent commits).

Prompt for AI agents (unresolved issues)

Check if these issues are valid — if so, understand the root cause of each and fix them. When an issue isn't valid or won't be fixed in this PR, reply in its thread with the reason and then resolve the thread. If appropriate, use sub-agents to investigate and fix each issue separately.


<file name="packages/platform-android/src/ime-activation.ts">

<violation number="1" location="packages/platform-android/src/ime-activation.ts:96">
P3: `settleRebindDisplacement` returns `true` (settled) without looking at the device record when `priorPersistedIme === undefined`, so an activation can claim `rebindUnconfirmed: false` while `agent_device_ime_helper_rebind_displaced` still reads `1`. That stale record can originate from the claim-already-active path (it never writes a previous-IME record) followed by a failed rebind, or from a crash between `clearPersistedPreviousIme` and `clearPersistedRebindDisplacement` in restore; `restoreAndroidTestImeFor` then sees the missing previous-IME record as `no-record` and never clears it. Later, `readActivationRestoreTarget` keeps `priorPersistedIme` instead of the current IME, and close-time restore treats the stale `1` as a genuine displacement and runs `ime set previousIme`, overriding an IME the user switched to mid-session — the exact 'user's own switch' invariant the restore path otherwise preserves. Gate the settle on the record itself instead of on the prior-IME record.</violation>

<violation number="2" location="packages/platform-android/src/ime-activation.ts:215">
P1: Publish ownership before awaiting the displacement-record cleanup. If that ADB read/delete rejects after the helper switch, activation fails with the marker and restore record still present, but close-time restore sees no in-process owner and leaves the user on the helper until a later startup recovery; keep the owner marked unconfirmed, then update its flag after settlement.</violation>
</file>

Tip: Review your code locally with the cubic CLI to iterate faster.

Fix all with cubic | Re-trigger cubic

Comment on lines +215 to +219
const rebindSettled = await settleRebindDisplacement(adb, priorPersistedIme);
activeTestImeDevices.set(deviceKey, {
stateDir: options.stateDir,
rebindUnconfirmed: !rebindSettled,
});

@cubic-dev-ai cubic-dev-ai Bot Sep 30, 2026 •

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.

P1: Publish ownership before awaiting the displacement-record cleanup. If that ADB read/delete rejects after the helper switch, activation fails with the marker and restore record still present, but close-time restore sees no in-process owner and leaves the user on the helper until a later startup recovery; keep the owner marked unconfirmed, then update its flag after settlement.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. When an issue isn't valid or won't be fixed in this PR, reply in its thread with the reason and then resolve the thread. At packages/platform-android/src/ime-activation.ts, line 215:

<comment>Publish ownership before awaiting the displacement-record cleanup. If that ADB read/delete rejects after the helper switch, activation fails with the marker and restore record still present, but close-time restore sees no in-process owner and leaves the user on the helper until a later startup recovery; keep the owner marked unconfirmed, then update its flag after settlement.</comment>

<file context>
@@ -222,8 +212,11 @@ async function activateAndroidTestImeAfterStartupRecovery(
   // active ownership after the helper is confirmed active on the device.
-  activeTestImeDevices.set(deviceKey, { stateDir: options.stateDir, rebindUnconfirmed: false });
-  await settleRebindDisplacement(adb, priorPersistedIme);
+  const rebindSettled = await settleRebindDisplacement(adb, priorPersistedIme);
+  activeTestImeDevices.set(deviceKey, {
+    stateDir: options.stateDir,
</file context>
Suggested change
const rebindSettled = await settleRebindDisplacement(adb, priorPersistedIme);
activeTestImeDevices.set(deviceKey, {
stateDir: options.stateDir,
rebindUnconfirmed: !rebindSettled,
});
const ownership = {
stateDir: options.stateDir,
rebindUnconfirmed: true,
};
activeTestImeDevices.set(deviceKey, ownership);
const rebindSettled = await settleRebindDisplacement(adb, priorPersistedIme);
ownership.rebindUnconfirmed = !rebindSettled;
Fix with cubic

Comment on lines +96 to +97
if (priorPersistedIme === undefined) return true;
return await clearPersistedRebindDisplacement(adb);

@cubic-dev-ai cubic-dev-ai Bot Sep 30, 2026 •

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.

P3: settleRebindDisplacement returns true (settled) without looking at the device record when priorPersistedIme === undefined, so an activation can claim rebindUnconfirmed: false while agent_device_ime_helper_rebind_displaced still reads 1. That stale record can originate from the claim-already-active path (it never writes a previous-IME record) followed by a failed rebind, or from a crash between clearPersistedPreviousIme and clearPersistedRebindDisplacement in restore; restoreAndroidTestImeFor then sees the missing previous-IME record as no-record and never clears it. Later, readActivationRestoreTarget keeps priorPersistedIme instead of the current IME, and close-time restore treats the stale 1 as a genuine displacement and runs ime set previousIme, overriding an IME the user switched to mid-session — the exact 'user's own switch' invariant the restore path otherwise preserves. Gate the settle on the record itself instead of on the prior-IME record.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. When an issue isn't valid or won't be fixed in this PR, reply in its thread with the reason and then resolve the thread. At packages/platform-android/src/ime-activation.ts, line 96:

<comment>`settleRebindDisplacement` returns `true` (settled) without looking at the device record when `priorPersistedIme === undefined`, so an activation can claim `rebindUnconfirmed: false` while `agent_device_ime_helper_rebind_displaced` still reads `1`. That stale record can originate from the claim-already-active path (it never writes a previous-IME record) followed by a failed rebind, or from a crash between `clearPersistedPreviousIme` and `clearPersistedRebindDisplacement` in restore; `restoreAndroidTestImeFor` then sees the missing previous-IME record as `no-record` and never clears it. Later, `readActivationRestoreTarget` keeps `priorPersistedIme` instead of the current IME, and close-time restore treats the stale `1` as a genuine displacement and runs `ime set previousIme`, overriding an IME the user switched to mid-session — the exact 'user's own switch' invariant the restore path otherwise preserves. Gate the settle on the record itself instead of on the prior-IME record.</comment>

<file context>
@@ -80,15 +81,20 @@ async function readActivationRestoreTarget(
-): Promise<void> {
-  if (priorPersistedIme !== undefined) await clearPersistedRebindDisplacement(adb);
+): Promise<boolean> {
+  if (priorPersistedIme === undefined) return true;
+  return await clearPersistedRebindDisplacement(adb);
 }
</file context>
Suggested change
if (priorPersistedIme === undefined) return true;
return await clearPersistedRebindDisplacement(adb);
const record = await readPersistedRebindDisplacement(adb);
if (record === false) return true;
return await clearPersistedRebindDisplacement(adb);
Fix with cubic

@thymikee thymikee left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Code-quality review. The fix works, but the rebind is bolted on rather than modeled. It is a third IME lifecycle transaction living inside the activation module. Its state is spread across a durable settings key, a mutable in-memory flag and four readers that each treat "can't read the key" differently. The fill path and the type path also handle a failed rebind differently. Inline comments cover each point. They skip points already raised (restore !== true fail-open, allowFailure on ime verbs, the hardcoded component, comment wording, settle ordering, live-run request).

The PR body is also stale. It says a failed rebind drops the device from activeTestImeDevices, but since 8dd4668 ownership is kept with rebindUnconfirmed: true.

beforeTarget: AndroidFillVerification['targetInput'],
): Promise<boolean> {
if (isAndroidImeCommitDropped(lastVerification, beforeTarget)) {
if (!(await rebindAndroidTestIme(device))) return false;

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

The same failure, a rebind that cannot confirm the helper, gives two different results depending on the call path. In admitAndroidTextChannel it throws a typed COMMAND_FAILED with reason: 'android_test_ime_rebind_unconfirmed' and a "close and reopen the session" hint. Here it returns false, the loop breaks, and the caller reports the first attempt's generic "Android fill verification failed" with no rebind reason. Because of this, the new test "fillAndroid stops on an unconfirmed rebind…" can only assert code: 'COMMAND_FAILED'. An agent that reads that error retries the fill, which then fails differently through the admit path.

Use one policy. Call confirmAndroidTestImeRebound(device) here and throw instead of returning a boolean. prepareAndroidImeHelperRetry then returns void, the lastVerification && !(await …) break guard goes away, and the fill test can assert details.reason === 'android_test_ime_rebind_unconfirmed' like the type tests do.

* is cleared only on confirmation, so restore — at close or after a crash — returns an unconfirmed
* device to the user's IME even when Android fell back to another one.
*/
export async function rebindAndroidTestIme(device: DeviceInfo): Promise<boolean> {

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

This is a third IME lifecycle transaction, next to activation and restore. It has its own lock section, durable record, diagnostics and failure modes, but it is appended to ime-activation.ts (204 → 329 lines), and its eight tests went into ime-activation.test.ts (139 → 388). Move rebindAndroidTestIme and rebindAndReadSelectedIme to ime-rebind.ts, and their tests to ime-rebind.test.ts.

While moving it, fix the contract. A bare boolean collapses four outcomes: no owner, ownership replaced, record write failed, and helper not selected or unreadable. The rest goes through a side channel that mutates ownership.rebindUnconfirmed on the shared map value (L285, L298). Return a discriminated outcome instead, for example { kind: 'confirmed' } | { kind: 'not-owned' } | { kind: 'unconfirmed', cause: 'record-write' | 'helper-not-selected' | 'read-failed' }, so callers switch on one value. This also fixes the "helper not selected" message, which is wrong for read failures.

}

/** Whether the device record marks an unconfirmed rebind, or `undefined` when it cannot be read. */
export async function readPersistedRebindDisplacement(

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

readPersistedRebindDisplacement returns boolean | undefined, and each caller decides on the spot what undefined means:

  • readActivationRestoreTarget treats only === false as clear.
  • claimAlreadyActiveTestIme (ime-activation.ts:253) uses !== false.
  • restore (ime-restore.ts:88) uses !== true, which is the fail-open already flagged.
  • the write and clear helpers turn it back into a boolean.

On top of that, ownership.rebindUnconfirmed is an in-memory copy of the same fact, and ime-activation.ts:252 ORs the two together. That gives two sources of truth and four separate readings of an unknown value. This is how the restore bug got in, and the next reader will add a fifth reading.

Model the device record once. Use one reader, for example readAndroidTestImeDeviceRecord(adb): { kind: 'unreadable' } | { kind: 'absent' } | { kind: 'owned'; previousIme: string; rebindDisplaced: boolean }, that fails closed on unreadable in one place. Activation, the idempotent claim and restore then switch on that union. Derive the in-memory flag from the record instead of keeping it beside it.

}

/** Whether none of a helper commit reached the field: it shows its hint, or the value it held before. */
function isAndroidImeCommitDropped(

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

isAndroidImeCommitDropped combines two clauses that buildAndroidFillUnconfirmedVerification already checks: hintShowing === true (fill-verification.ts:165) and beforeTarget.text === verification.actual (fill-verification.ts:174). The "nothing of the commit landed" rule now lives in two modules and can drift. Export isAndroidFillCommitDropped(verification, beforeTarget) from fill-verification.ts, use it in the soft-success guard in place of those two clauses, and import it here. Move its test cases into fill-verification.test.ts.

// the field may also have gone to a stale input session, which only a rebind of the IME replaces.
for (let attempt = 0; attempt < 2; attempt += 1) {
if (attempt > 0) await focusAndroid(device, x, y);
if (

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

This is now a 2-iteration for whose counter is never read. The retry gate is lastVerification && !(await prepare…) with a break, and the result comes back as lastVerification as AndroidFillVerification. What the code does is: try once, and if the failure is not a soft success, prepare and try again. Write it that way with a local attemptFill():

const first = await attemptFill();
if (first.ok || buildAndroidFillUnconfirmedVerification(…)) return first;
await prepareAndroidImeHelperRetry(…);
return await attemptFill();

The cast and the null-initialised accumulator go away. With the throw from the L303 comment, the boolean return goes away as well.

This branch has not been deployed

No deployments
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.

Android test-IME fill commits into a stale InputConnection session after focus moves to a new field

3 participants