diff --git a/Ports/iOSPort/nativeSources/CodenameOne_GLViewController.m b/Ports/iOSPort/nativeSources/CodenameOne_GLViewController.m index 4942a3cd56..d789c399b7 100644 --- a/Ports/iOSPort/nativeSources/CodenameOne_GLViewController.m +++ b/Ports/iOSPort/nativeSources/CodenameOne_GLViewController.m @@ -2701,7 +2701,21 @@ - (void)remoteControlReceivedWithEvent:(UIEvent *)receivedEvent { // Mac Catalyst host keyboard, hardware keyboard in the iOS simulator via // Cmd-Shift-K). UIKey arrived in iOS 13.4 -- on older versions the // responder chain falls back to the existing UITextField editing path. +// +// While a native text editor is up (editingComponent != nil) we must not +// consume any UIPress -- UIKit's text-input pipeline needs every press +// (including printable characters, which cn1MapUIKeyToKeyCode returns as +// their unicode codepoint) to reach the focused CN1UITextField / +// CN1UITextView for insertion. The original implementation swallowed +// every press that mapped to a non-zero CN1 keycode, which on iOS 13.4+ +// broke hardware-keyboard typing outright and on iOS 26.x devices, where +// some on-screen keyboard interactions also surface as UIPress events, +// broke virtual-keyboard typing too -- see #5010. - (void)pressesBegan:(NSSet *)presses withEvent:(UIPressesEvent *)event { + if (editingComponent != nil) { + [super pressesBegan:presses withEvent:event]; + return; + } if (@available(iOS 13.4, *)) { BOOL handled = NO; NSMutableSet *passthrough = nil; @@ -2732,6 +2746,10 @@ - (void)pressesBegan:(NSSet *)presses withEvent:(UIPressesEvent *)eve } - (void)pressesEnded:(NSSet *)presses withEvent:(UIPressesEvent *)event { + if (editingComponent != nil) { + [super pressesEnded:presses withEvent:event]; + return; + } if (@available(iOS 13.4, *)) { BOOL handled = NO; NSMutableSet *passthrough = nil; @@ -2762,6 +2780,10 @@ - (void)pressesEnded:(NSSet *)presses withEvent:(UIPressesEvent *)eve } - (void)pressesCancelled:(NSSet *)presses withEvent:(UIPressesEvent *)event { + if (editingComponent != nil) { + [super pressesCancelled:presses withEvent:event]; + return; + } if (@available(iOS 13.4, *)) { for (UIPress *press in presses) { UIKey *key = press.key; diff --git a/scripts/input-validation-app/README.adoc b/scripts/input-validation-app/README.adoc index c27929fd81..6fc71c1be4 100644 --- a/scripts/input-validation-app/README.adoc +++ b/scripts/input-validation-app/README.adoc @@ -1,8 +1,8 @@ = CN1 Input Validation App A minimal Codename One app whose only purpose is to assert that physical -input events (tap, drag, long-press) reach Component listeners end-to-end -on iOS. Driven by XCUITest on the iOS simulator. +input events (tap, drag, long-press, keyboard) reach Component listeners +end-to-end on iOS. Driven by XCUITest on the iOS simulator. == Why a separate pipeline? @@ -44,6 +44,15 @@ the expected time. | `addLongPressListener` fires for a press-and-hold. Routes through the same touch chain as tap, so a recognizer that cancels touches mid-press causes this step to time out. + +| `keytype` +| Typed characters reach a CN1 `TextField.DataChangedListener`. The + driver taps the field to bring up native iOS editing, then synthesises + keystrokes via `XCUIApplication.typeText`. typeText drives the + simulator's HW-keyboard pathway, which on iOS 13.4+ surfaces as + `UIPress` events through `GLViewController` -- the path #5010 broke + by treating every printable key as "handled" and never forwarding it + to UIKit's text-input pipeline. |=== == Log markers diff --git a/scripts/input-validation-app/common/src/main/java/com/codenameone/inputvalidation/gestures/GestureSuite.java b/scripts/input-validation-app/common/src/main/java/com/codenameone/inputvalidation/gestures/GestureSuite.java index 1b58d95cef..f86da84827 100644 --- a/scripts/input-validation-app/common/src/main/java/com/codenameone/inputvalidation/gestures/GestureSuite.java +++ b/scripts/input-validation-app/common/src/main/java/com/codenameone/inputvalidation/gestures/GestureSuite.java @@ -38,7 +38,8 @@ public GestureSuite() { this.steps = new GestureStep[] { new TapStep(), new DragStep(), - new LongPressStep() + new LongPressStep(), + new KeyTypeStep() }; this.form = new Form("Input Validation", new BorderLayout()); this.statusLabel = new Label("Initializing"); diff --git a/scripts/input-validation-app/common/src/main/java/com/codenameone/inputvalidation/gestures/KeyTypeStep.java b/scripts/input-validation-app/common/src/main/java/com/codenameone/inputvalidation/gestures/KeyTypeStep.java new file mode 100644 index 0000000000..28bbed5a53 --- /dev/null +++ b/scripts/input-validation-app/common/src/main/java/com/codenameone/inputvalidation/gestures/KeyTypeStep.java @@ -0,0 +1,68 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. All rights reserved. + * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER. + */ +package com.codenameone.inputvalidation.gestures; + +import com.codename1.ui.Container; +import com.codename1.ui.Font; +import com.codename1.ui.TextArea; +import com.codename1.ui.TextField; +import com.codename1.ui.layouts.BorderLayout; + +/// Validates that keyboard input lands in a CN1 TextField end-to-end. +/// The XCUITest driver taps the field to bring up native iOS editing +/// (CN1UITextField becomes first responder), then synthesises keystrokes +/// via `XCUIApplication.typeText`. typeText drives the simulator's +/// hardware-keyboard pathway, which on iOS 13.4+ surfaces as UIPress +/// events that walk the responder chain through `GLViewController`. +/// +/// Regression coverage for #5010: a pressesBegan: handler in +/// CodenameOne_GLViewController.m treated every UIPress whose UIKey +/// mapped to a non-zero CN1 keycode as consumed -- and printable +/// characters map to their unicode codepoint, which is always non-zero. +/// That swallowed every HW keystroke before UIKit could convert it into +/// insertText: on the focused CN1UITextField, and on iOS 26.x devices +/// the same path also swallowed virtual-keyboard input. The fix bypasses +/// the intercept while editingComponent != nil. This step fails to +/// receive EXPECTED_TEXT and times out if either bug ever returns. +public final class KeyTypeStep implements GestureStep { + /// XCUITest types this exact string. Kept short, lowercase, and not a + /// dictionary word so iOS auto-capitalisation / auto-correct cannot + /// silently rewrite the characters before the CN1 TextField sees them. + public static final String EXPECTED_TEXT = "cn1"; + + @Override + public String name() { + return "keytype"; + } + + @Override + public void install(Container target, Callback callback) { + // Disable predictive text in the constraint so simulated keystrokes + // land verbatim. Without this iOS auto-capitalisation can rewrite + // the first character (`Cn1` instead of `cn1`) and make the + // assertion brittle across keyboard configurations. + final TextField field = new TextField("", "Type " + EXPECTED_TEXT + " here", + EXPECTED_TEXT.length() + 8, + TextArea.ANY | TextArea.NON_PREDICTIVE); + field.setName("cn1iv-keytype-target"); + // Match TapStep / LongPressStep tap-target sizing so the XCUITest + // driver can use the same (0.5, 0.5) coordinate to focus the + // field on every iPhone size class on the CI runner. A NORTH + // placement put the field above where the existing steps tap + // and the driver missed it -- see #5010 CI failure. + field.getAllStyles().setFont(Font.createSystemFont(Font.FACE_SYSTEM, Font.STYLE_BOLD, Font.SIZE_LARGE)); + field.getAllStyles().setPadding(48, 48, 48, 48); + field.getAllStyles().setMargin(48, 48, 48, 48); + final boolean[] fired = {false}; + field.addDataChangedListener((type, index) -> { + String text = field.getText(); + if (!fired[0] && text != null && text.contains(EXPECTED_TEXT)) { + fired[0] = true; + callback.onDetected("text=" + text); + } + }); + target.add(BorderLayout.CENTER, field); + } +} diff --git a/scripts/input-validation-app/drivers/run-ios.sh b/scripts/input-validation-app/drivers/run-ios.sh index 22a0ec14af..6ebc1c83e5 100755 --- a/scripts/input-validation-app/drivers/run-ios.sh +++ b/scripts/input-validation-app/drivers/run-ios.sh @@ -187,6 +187,8 @@ REQUIRED_EVENTS=( "CN1IV:EVENT:drag" "CN1IV:READY:longpress" "CN1IV:EVENT:longpress" + "CN1IV:READY:keytype" + "CN1IV:EVENT:keytype" "CN1IV:SUITE:FINISHED" ) FAILED=0 diff --git a/scripts/input-validation-app/ios-tests/Sources/InputValidationUITests.swift b/scripts/input-validation-app/ios-tests/Sources/InputValidationUITests.swift index 725871a2a4..96f8484dd2 100644 --- a/scripts/input-validation-app/ios-tests/Sources/InputValidationUITests.swift +++ b/scripts/input-validation-app/ios-tests/Sources/InputValidationUITests.swift @@ -52,6 +52,9 @@ final class InputValidationUITests: XCTestCase { try driveLongPress(app: app) Thread.sleep(forTimeInterval: stepDelaySeconds) + + try driveKeyType(app: app) + Thread.sleep(forTimeInterval: stepDelaySeconds) } private func driveTap(app: XCUIApplication) throws { @@ -71,4 +74,21 @@ final class InputValidationUITests: XCTestCase { let center = app.coordinate(withNormalizedOffset: CGVector(dx: 0.5, dy: 0.5)) center.press(forDuration: 1.5) } + + private func driveKeyType(app: XCUIApplication) throws { + // KeyTypeStep places its TextField in BorderLayout.CENTER with + // generous padding/margin, matching the layout TapStep and + // LongPressStep use so a single screen-center tap focuses it on + // every iPhone size class on the CI runner. Wait briefly after + // the tap for CN1's editStringAtImpl to install the native + // CN1UITextField and animate the keyboard in -- typeText raises + // "Neither element nor any descendant has keyboard focus" if no + // first responder accepts input yet -- then type. typeText + // synthesises HW-keyboard UIPress events that walk through + // GLViewController, exactly the path #5010 broke. + let center = app.coordinate(withNormalizedOffset: CGVector(dx: 0.5, dy: 0.5)) + center.tap() + Thread.sleep(forTimeInterval: 2.0) + app.typeText("cn1") + } }