Skip to content

Commit 9ef444a

Browse files
committed
fix(ios): stop fill from failing and repairing fields that normalize their input (#2634)
The bug was textEntryValueEchoes' containment clauses, not the repair gate. A field that reformats what it holds (a '## ### ## ##' digit grouping, a phone mask, a currency format) shows the request with the formatter's separators inserted; clauses 2/3 classify that as an echo, which withheld the unconfirmed outcome, so the fill fell to mismatch -> destructive clear-and-retype of a correct field -> TEXT_ENTRY_MISMATCH. Measured on iPhone 18 Pro / iOS 27.0 against a digit-grouping fixture: REPAIR_TEXT_ENTRY expectedLength=9 observedLength=12, then the mismatch. A value that COMPLETES the request now falls through to the existing unconfirmed-evidence path: every request character in order, plus inserted characters strictly between the request's own characters that appear nowhere in the request. Degradation removes; entry cannot insert a separator between two characters the same burst typed, and a failed clear leaves residual text at the ENDS of the typed run — so end-only insertions ('old123456') stay echoes and keep their repair, while an interior foreign insertion does not. Leftmost embedding and a no-request-character-extras guard keep doubled entries and ambiguous embeddings on the failure side. This deliberately flips the pin that '(555) 123-4567' IS an echo: 'echo' means 'could be a degraded copy', and a complete mask value is not one. Completion is never a correctness claim — a cents-shifting mask passes it — so the outcome is unconfirmed with before/after evidence, never verified: true; only exact equality verifies. fill-evidence.ts already names app-owned formatting as this shape's purpose; Android already reports it unconfirmed; iOS was the outlier. isRepairableTextEntryMismatch and the synthesized-replacement commit wait are UNCHANGED; the coordinate route's exact-match-only settle is pinned as an explicit non-goal. The write-back-truncation shape (ada@example -> adxe) stays a typed failure under #2903's ownership, and so do drops, stale residuals, and doubled entries. Fixture: AgentDeviceDigitGroupingTextField reproduces the reported formatter and restores the caret across reformats the way the real formatter it models does; both normalizing device tests share one fill-and-assert helper so their plumbing cannot drift. The device test proves the repair no longer runs (message 'typed', not 'typed after repair') and the field keeps 00 062 91 77.
1 parent 24b2ce6 commit 9ef444a

6 files changed

Lines changed: 358 additions & 17 deletions

File tree

‎apple/runner/AgentDeviceRunner/AgentDeviceRunner/AgentDeviceRunnerApp.m‎

Lines changed: 93 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -97,6 +97,85 @@ - (NSString *)accessibilityValue {
9797
return [NSString stringWithFormat:@"%lu of 6 digits", (unsigned long)self.text.length];
9898
}
9999
@end
100+
101+
// A field that reformats its own text on every edit into `## ### ## ##` digit groups, the way a
102+
// digit-grouping formatter in a Flutter or React Native input does. The accessibility value is
103+
// what the user sees — the formatted string — so it never equals the digits a `fill` sent (#2634).
104+
// The field owns the delegate it installs, keeping the formatter out of the view controller's
105+
// own delegate callbacks.
106+
@interface AgentDeviceDigitGroupingTextField : UITextField <UITextFieldDelegate>
107+
@end
108+
109+
static NSString *AgentDeviceFormatGroupedDigits(NSString *raw) {
110+
NSMutableString *digits = [NSMutableString string];
111+
NSCharacterSet *nonDigits = [[NSCharacterSet decimalDigitCharacterSet] invertedSet];
112+
for (NSString *chunk in [raw componentsSeparatedByCharactersInSet:nonDigits]) {
113+
[digits appendString:chunk];
114+
}
115+
NSMutableArray<NSNumber *> *groupWidths = [NSMutableArray arrayWithArray:@[@2, @3, @2, @2]];
116+
NSMutableString *formatted = [NSMutableString string];
117+
NSUInteger cursor = 0;
118+
for (NSNumber *width in groupWidths) {
119+
if (cursor >= digits.length) {
120+
break;
121+
}
122+
if (formatted.length > 0) {
123+
[formatted appendString:@" "];
124+
}
125+
NSUInteger end = MIN(cursor + width.unsignedIntegerValue, digits.length);
126+
[formatted appendString:[digits substringWithRange:NSMakeRange(cursor, end - cursor)]];
127+
cursor = end;
128+
}
129+
// Digits past the last group keep grouping by threes rather than vanishing from the field.
130+
while (cursor < digits.length) {
131+
NSUInteger end = MIN(cursor + 3, digits.length);
132+
[formatted appendString:[NSString stringWithFormat:@" %@", [digits substringWithRange:NSMakeRange(cursor, end - cursor)]]];
133+
cursor = end;
134+
}
135+
return formatted;
136+
}
137+
138+
static NSUInteger AgentDeviceDigitCount(NSString *text, NSUInteger limit) {
139+
NSUInteger count = 0;
140+
for (NSUInteger index = 0; index < limit && index < text.length; index++) {
141+
if ([NSCharacterSet.decimalDigitCharacterSet characterIsMember:[text characterAtIndex:index]]) {
142+
count += 1;
143+
}
144+
}
145+
return count;
146+
}
147+
148+
@implementation AgentDeviceDigitGroupingTextField
149+
- (BOOL)textField:(UITextField *)textField
150+
shouldChangeCharactersInRange:(NSRange)range
151+
replacementString:(NSString *)string {
152+
NSString *combined = [textField.text stringByReplacingCharactersInRange:range withString:string];
153+
NSString *formatted = AgentDeviceFormatGroupedDigits(combined);
154+
textField.text = formatted;
155+
// Assigning `text` drops the caret at the end, which a real grouping formatter does not do:
156+
// a mid-string edit must keep editing where the user put the caret. The digits before the
157+
// caret survive the reformat unchanged, so the caret belongs before the digit that follows
158+
// them — past the separators the grouping inserted behind them — and at the end when it
159+
// followed all of them. Typed digits count as before the caret; a delete does not move it.
160+
NSUInteger digitsBeforeCaret =
161+
AgentDeviceDigitCount(textField.text, range.location) + AgentDeviceDigitCount(string, string.length);
162+
NSUInteger caretOffset = formatted.length;
163+
NSUInteger seen = 0;
164+
for (NSUInteger index = 0; index < formatted.length; index++) {
165+
if (![NSCharacterSet.decimalDigitCharacterSet characterIsMember:[formatted characterAtIndex:index]]) {
166+
continue;
167+
}
168+
seen += 1;
169+
if (seen == digitsBeforeCaret + 1) {
170+
caretOffset = index;
171+
break;
172+
}
173+
}
174+
textField.selectedTextRange = [textField textRangeFromPosition:[textField positionFromPosition:textField.beginningOfDocument offset:caretOffset]
175+
toPosition:[textField positionFromPosition:textField.beginningOfDocument offset:caretOffset]];
176+
return NO;
177+
}
178+
@end
100179
#endif
101180

102181
@implementation AgentDeviceRunnerViewController
@@ -408,8 +487,20 @@ - (void)viewDidLoad {
408487
if ([NSProcessInfo.processInfo.arguments containsObject:@"--agent-device-text-entry-regression"]) {
409488
BOOL digitCountValue =
410489
[NSProcessInfo.processInfo.arguments containsObject:@"--agent-device-text-entry-digit-count-value"];
411-
UITextField *textField =
412-
digitCountValue ? [[AgentDeviceDigitCountTextField alloc] init] : [[UITextField alloc] init];
490+
BOOL digitGroupingValue =
491+
[NSProcessInfo.processInfo.arguments containsObject:@"--agent-device-text-entry-digit-grouping-value"];
492+
UITextField *textField;
493+
if (digitCountValue) {
494+
textField = [[AgentDeviceDigitCountTextField alloc] init];
495+
} else if (digitGroupingValue) {
496+
textField = [[AgentDeviceDigitGroupingTextField alloc] init];
497+
} else {
498+
textField = [[UITextField alloc] init];
499+
}
500+
if (digitGroupingValue) {
501+
// The formatter field is its own delegate: every edit is reformatted before it lands.
502+
textField.delegate = (id<UITextFieldDelegate>)textField;
503+
}
413504
if (![NSProcessInfo.processInfo.arguments containsObject:@"--agent-device-text-entry-unnamed-input"]) {
414505
textField.accessibilityIdentifier = @"agent-device-hardware-keyboard-input";
415506
}

‎apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests+TextEntryConfirmation.swift‎

Lines changed: 56 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -44,14 +44,68 @@ extension RunnerTests {
4444
/// the baseline did not already (residual text), or it contains baseline + request in order. A
4545
/// value related to the entry in none of these ways, such as an OTP field announcing
4646
/// "6 of 6 digits", is the app's own representation, so retyping cannot make it match.
47+
///
48+
/// The two containment clauses keep one exception (#2634): a value the request plus inserted
49+
/// formatting alone EXPLAINS — see `textValueCompletesRequest`, which owns the whole test
50+
/// including the baseline — is not a degraded copy of anything, and the field that normalizes
51+
/// its content shows exactly that after receiving the request. Completion is not correctness
52+
/// (a cents-shifting mask passes it); it only stops the value from being refused the
53+
/// unconfirmed outcome, and nothing but exact equality verifies.
4754
static func textEntryValueEchoes(observed: String, expected: String, baseline: String) -> Bool {
4855
let request = textEntryRequestWithoutSubmitKeys(expected)
4956
let residualAndRequest = baseline + request
50-
return isOrderedSubsequence(observed, of: residualAndRequest)
51-
|| (isOrderedSubsequence(request, of: observed) && !isOrderedSubsequence(request, of: baseline))
57+
if isOrderedSubsequence(observed, of: residualAndRequest) {
58+
return true
59+
}
60+
if textValueCompletesRequest(observed: observed, request: request, baseline: baseline) {
61+
return false
62+
}
63+
return (isOrderedSubsequence(request, of: observed) && !isOrderedSubsequence(request, of: baseline))
5264
|| isOrderedSubsequence(residualAndRequest, of: observed)
5365
}
5466

67+
/// Whether the request plus inserted formatting explains the WHOLE of `observed`: every request
68+
/// character in order, every remaining character outside it explainable only as an insertion —
69+
/// not a character of the request (an ambiguous embedding may be a dropped-and-shifted copy)
70+
/// and not a character of the post-clear `baseline` either, since a partial clear's residual
71+
/// text may sit anywhere a mask relocates it — and at least one insertion strictly BETWEEN the
72+
/// request's first and last characters, because entry cannot insert between two characters the
73+
/// same burst typed while a failed clear's residual may only be appended or prepended
74+
/// (`"old123456"` stays an echo). The embedding is the leftmost one, so a doubled entry (`"66"`
75+
/// for `"6"`) leaves its surplus at the ends; a mask inserting a request character (`.` for a
76+
/// decimal value) falls back to the echo reading; a one-character request has no between.
77+
static func textValueCompletesRequest(observed: String, request: String, baseline: String) -> Bool {
78+
guard !request.isEmpty, request != observed, request.count > 1 else {
79+
return false
80+
}
81+
// The leftmost embedding of the request into the observed value.
82+
var consumedOffsets = IndexSet()
83+
var cursor = observed.startIndex
84+
for character in request {
85+
guard let match = observed[cursor...].firstIndex(of: character) else {
86+
return false
87+
}
88+
consumedOffsets.insert(observed.distance(from: observed.startIndex, to: match))
89+
cursor = observed.index(after: match)
90+
}
91+
// A one-character request has no between for an insertion to sit in.
92+
let first = consumedOffsets.first!
93+
let last = consumedOffsets.last!
94+
var sawInteriorInsertion = false
95+
for (offset, character) in observed.enumerated() where !consumedOffsets.contains(offset) {
96+
// A request character makes the embedding ambiguous; a baseline character may be residual
97+
// a mask relocated into the span. Either way the request plus formatting does not explain
98+
// the value.
99+
if request.contains(character) || baseline.contains(character) {
100+
return false
101+
}
102+
if offset > first && offset < last {
103+
sawInteriorInsertion = true
104+
}
105+
}
106+
return sawInteriorInsertion
107+
}
108+
55109
/// Classifies a replacement whose read-back never matched. The entry is unconfirmed, not failed,
56110
/// only when the same element's value moved off its pre-entry baseline to one that does not echo
57111
/// the request; every other mismatch stays a failure.

0 commit comments

Comments
 (0)