Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
32 commits
Select commit Hold shift + click to select a range
c64e0ba
Phone verification: the code field, and the hint that fills it
shai-almog Aug 31, 2026
4d69e3e
Phone verification: the hint, on every port that can act on it
shai-almog Aug 31, 2026
407e03d
Phone verification: tests, including one that looks at pixels
shai-almog Aug 31, 2026
e01d782
Phone verification: themes, guide, agent skill and a sample
shai-almog Aug 31, 2026
fb6105f
Phone verification: drop an import the rewrite left behind
shai-almog Aug 31, 2026
eeea388
Phone verification: a chapter of its own in the developer guide
shai-almog Aug 31, 2026
aa85bad
Phone verification: release the content type ivar
shai-almog Aug 31, 2026
26aeddc
Phone verification: three things that outlive what asked for them
shai-almog Aug 31, 2026
b5e608a
Phone verification: a caret in the wrong space, and two unfiltered doors
shai-almog Aug 31, 2026
30ce24a
Phone verification: an example that contradicted the paragraph under it
shai-almog Aug 31, 2026
edfa0bc
Phone verification: make a tap land on the box it hit
shai-almog Aug 31, 2026
c0f2744
Phone verification: two Android autofill reads that crossed threads
shai-almog Aug 31, 2026
65bb2d9
Phone verification: a code reads left to right, and a fill needs its …
shai-almog Aug 31, 2026
0ed8a82
Phone verification: a keyboard that rewrites codes, and a stale selec…
shai-almog Aug 31, 2026
9c9f8a8
Phone verification: take the country object the list actually carries
shai-almog Aug 31, 2026
1ce8b95
Phone verification: boxes that fit, and a value read only for the fie…
shai-almog Aug 31, 2026
50c6439
Phone verification: focus that had nowhere to go, and a countdown tha…
shai-almog Aug 31, 2026
6291ba6
Phone verification: an ISO lookup that a Turkish device could not per…
shai-almog Aug 31, 2026
e1946a3
Phone verification: search the country list without asking the locale…
shai-almog Aug 31, 2026
9964576
Phone verification: do not answer with a value the input method is st…
shai-almog Aug 31, 2026
7806908
Phone verification: say why eight ISO codes are not in the country table
shai-almog Aug 31, 2026
1c39e25
Phone verification: the composing range does not outlive a commit -- …
shai-almog Aug 31, 2026
06c12a4
Phone verification: a number that carries its own calling code keeps it
shai-almog Aug 31, 2026
2007248
Phone verification: the PMD gate's two complaints
shai-almog Aug 31, 2026
35a3516
Phone verification: one session per answer, and a selection the picke…
shai-almog Aug 31, 2026
5262b7f
Phone verification: end the composition before a filled value replace…
shai-almog Aug 31, 2026
f2c69fc
Phone verification: a modifier bit must not erase the base constraint
shai-almog Aug 31, 2026
3eb4fea
Phone verification: leaving the form does not retire a pending request
shai-almog Aug 31, 2026
a1442bc
Phone verification: the copyright header the new test file was missing
shai-almog Aug 31, 2026
1915f93
Phone verification: the fourth door, and a number that was not sent t…
shai-almog Aug 31, 2026
7d0fe22
Phone verification: completion belongs to a value, not to a transition
shai-almog Aug 31, 2026
c39a2ee
Phone verification: tell the autofill framework the field changed, no…
shai-almog Aug 31, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
573 changes: 461 additions & 112 deletions CodenameOne/src/com/codename1/components/OtpField.java

Large diffs are not rendered by default.

744 changes: 744 additions & 0 deletions CodenameOne/src/com/codename1/components/PhoneNumberField.java

Large diffs are not rendered by default.

669 changes: 669 additions & 0 deletions CodenameOne/src/com/codename1/components/PhoneVerification.java

Large diffs are not rendered by default.

17 changes: 17 additions & 0 deletions CodenameOne/src/com/codename1/ui/TextArea.java
Original file line number Diff line number Diff line change
Expand Up @@ -132,6 +132,23 @@ public class TextArea extends Component implements ActionSource, TextHolder {
/// This flag is a hint to the implementation that the text in this
/// field should be upper case
public static final int UPPERCASE = 0x800000;
/// This flag is a hint to the implementation that this field holds a
/// one-time code the user received out of band, typically by SMS.
///
/// It is the client half of phone number verification: the code is
/// delivered to the device by a message the application never reads, and
/// the platform offers it on a field carrying this hint. On iOS the
/// keyboard's suggestion bar offers the code from Messages, on Android the
/// autofill service offers it from the SMS. Neither route needs permission
/// to read messages, and neither is available to a field that does not say
/// what it is for -- which is what this flag says.
///
/// Combine it with `#NUMERIC` for the usual all digit code. The hint alone
/// changes no behavior on a platform that cannot offer the code.
///
/// See `com.codename1.components.OtpField` for a field that already carries
/// this and renders the code one box per digit.
public static final int ONE_TIME_CODE = 0x1000000;
Comment thread
shai-almog marked this conversation as resolved.
/// Indicates the enter key to be used for editing the text area and by the
/// text field
private static final char ENTER_KEY = '\n';
Expand Down
12 changes: 9 additions & 3 deletions CodenameOne/src/com/codename1/ui/TextField.java
Original file line number Diff line number Diff line change
Expand Up @@ -1265,11 +1265,17 @@ public void insertChars(String c) {
///
/// true if the String is valid
public boolean validChar(String c) {
if (getConstraint() == TextArea.NUMERIC) {
// The base type, not the whole constraint. Every modifier -- PASSWORD, SENSITIVE,
// NON_PREDICTIVE, USERNAME, UPPERCASE, ONE_TIME_CODE -- is a bit above the base,
// and comparing the whole value meant a single one of them turned a numeric field
// back into a field that took anything. Every other consumer of a constraint in
// the framework already masks; these comparisons were simply left behind.
int constraint = getConstraint() & 0xffff;
if (constraint == TextArea.NUMERIC) {
return c.charAt(0) >= '0' && c.charAt(0) <= '9';
} else if (getConstraint() == TextArea.PHONENUMBER) {
} else if (constraint == TextArea.PHONENUMBER) {
return (c.charAt(0) >= '0' && c.charAt(0) <= '9') || c.charAt(0) == '+';
} else if (getConstraint() == TextArea.DECIMAL) {
} else if (constraint == TextArea.DECIMAL) {
return (c.charAt(0) >= '0' && c.charAt(0) <= '9') || c.charAt(0) == '+' || c.charAt(0) == '-' || c.charAt(0) == '.' || c.charAt(0) == ',';
}

Expand Down
Binary file modified Ports/Android/src/AndroidMaterialTheme.res
Binary file not shown.
25 changes: 25 additions & 0 deletions Ports/Android/src/com/codename1/impl/android/AndroidAsyncView.java
Original file line number Diff line number Diff line change
Expand Up @@ -552,6 +552,31 @@ public InputConnection onCreateInputConnection(EditorInfo editorInfo) {
return super.onCreateInputConnection(editorInfo);
}

/// The platform's autofill, when a pure editor holds the input session. The rendering surface
/// is the view an autofill service sees while a field is being edited (see
/// `AndroidImplementation#updateEditorAutofill(android.view.View, boolean)`), so the value it
/// offers -- a one-time code out of an arriving SMS -- arrives here.
@Override
public void autofill(android.view.autofill.AutofillValue value) {
if (!AndroidImplementation.autofillEditor(value)) {
super.autofill(value);
}
}

@Override
public int getAutofillType() {
if (AndroidImplementation.hasActiveInputClient()) {
return AUTOFILL_TYPE_TEXT;
}
return super.getAutofillType();
}

@Override
public android.view.autofill.AutofillValue getAutofillValue() {
android.view.autofill.AutofillValue v = AndroidImplementation.editorAutofillValue();
return v != null ? v : super.getAutofillValue();
}

@Override
public boolean onCheckIsTextEditor() {
if (AndroidImplementation.hasActiveInputClient()) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -402,6 +402,167 @@ static boolean hasActiveInputClient() {
return activeInputClient != null;
}

/// The Android autofill hint for a one-time code, spelled out rather than referenced as
/// `View.AUTOFILL_HINT_SMS_OTP` because the constant is newer than the SDK this port
/// compiles against. The string is the contract: it is what an autofill service matches on.
private static final String AUTOFILL_HINT_SMS_OTP = "smsOTPCode";

/// What the platform may fill into the currently bound field, or null when it is not a field
/// the platform can fill.
///
/// Only the one-time code is offered. The rendering surface is a single view standing in for
/// whichever field is being edited, so claiming a hint puts the whole surface forward as that
/// kind of field -- true only while the code field holds the session, which is why the hint is
/// applied when a session starts and dropped when it ends.
private static String[] editorAutofillHints() {
com.codename1.ui.TextInputConfig cfg = activeInputConfig;
if (cfg != null && (cfg.getConstraint() & com.codename1.ui.TextArea.ONE_TIME_CODE) != 0) {
return new String[]{AUTOFILL_HINT_SMS_OTP};
}
return null;
}

/// Puts the surface forward as an autofillable field, or withdraws it, to match the field the
/// input session is bound to. Called on the UI thread as a session starts and stops.
///
/// #### Parameters
///
/// - `v`: the rendering view
///
/// - `sessionActive`: true while a client is bound
static void updateEditorAutofill(android.view.View v, boolean sessionActive) {
if (v == null || android.os.Build.VERSION.SDK_INT < 26) {
return;
}
android.view.autofill.AutofillManager afm =
(android.view.autofill.AutofillManager) v.getContext()
.getSystemService(android.view.autofill.AutofillManager.class);
String[] hints = sessionActive ? editorAutofillHints() : null;
if (hints == null) {
v.setImportantForAutofill(android.view.View.IMPORTANT_FOR_AUTOFILL_NO);
v.setAutofillHints((String[]) null);
if (afm != null) {
afm.notifyViewExited(v);
}
return;
}
v.setAutofillHints(hints);
v.setImportantForAutofill(android.view.View.IMPORTANT_FOR_AUTOFILL_YES);
if (afm != null) {
// the session only starts once the framework is told the view was entered; a view
// that merely carries hints is never offered anything
afm.notifyViewEntered(v);
}
}

/// Applies a value the platform filled in, replacing whatever the field held. Called by the
/// rendering view on the UI thread; the edit itself belongs to the EDT.
///
/// #### Parameters
///
/// - `value`: the value the autofill service supplied
///
/// #### Returns
///
/// true when the value was taken
static boolean autofillEditor(android.view.autofill.AutofillValue value) {
final com.codename1.ui.TextInputClient client = activeInputClient;
if (client == null || value == null || !value.isText()) {
Comment thread
shai-almog marked this conversation as resolved.
return false;
}
// Only into a field that asked for this. The hint lives on the surface and is put
// there and taken away on Android's UI thread, while the session it describes changes
// on the EDT, so for a moment after the user moves from a code field to an ordinary
// one the view still advertises smsOTPCode while the session behind it is something
// else. A fill delivered in that gap would otherwise land a code in whatever the user
// tapped into. Asking what the CURRENT session advertises closes it: the answer is
// read from the same field the identity check below uses.
if (editorAutofillHints() == null) {
return false;
}
com.codename1.ui.Display.getInstance().callSerially(
new ApplyAutofilledText(client, value.getTextValue().toString()));
return true;
}

/// Named rather than anonymous on purpose. An anonymous class here takes a number from the
/// same sequence as every other one in this file, so adding one renumbers the ones below it
/// and the cast-semantics baseline stops matching methods nobody touched.
private static final class ApplyAutofilledText implements Runnable {
private final com.codename1.ui.TextInputClient client;
private final String text;

ApplyAutofilledText(com.codename1.ui.TextInputClient client, String text) {
this.client = client;
this.text = text;
}

public void run() {
// The session may be gone: the platform fills on the UI thread and this runs a hop
// later on the EDT, and in between the user can have moved to another field or left
// the screen. Applying it then would edit a field nothing is bound to any more and
// fire its listeners -- and an OtpField's completion listener submits a code, so a
// late fill would verify one for a flow the user has already left. The rest of this
// bridge guards its callbacks the same way.
if (client != activeInputClient || editorAutofillHints() == null) {
return;
}
// A filled value replaces the field rather than being inserted at the caret: the
// platform is answering "the value is this", not typing into what is there. It
// still arrives as a commit rather than a raw range replacement, because a field
// filters what it accepts and a filled value has no more right to bypass that
// than a typed one -- an OTP field asked for six digits and can be handed
// "123-456" by an autofill service that kept the separator, and a replacement
// would leave the field holding a value it would never have let anyone type,
// never reaching the length that completes it.
// Ending any composition first. A commit replaces the composed range in
// preference to the selection, so selecting the whole field is not enough to
// replace the whole field while an input method is mid-word: the filled value
// would land inside the composition and leave whatever surrounded it, which
// for a code field means a full-length wrong code that submits itself.
client.finishComposing();
client.setSelectionRange(0, client.getTextLength());
client.commitText(text);
Comment thread
shai-almog marked this conversation as resolved.
Comment thread
shai-almog marked this conversation as resolved.
}
}

/// The value the platform should see for the bound field, or null when nothing is bound.
///
/// Answered from the state snapshot rather than the editor itself. This runs on Android's UI
/// thread whenever an autofill service asks what the field holds, while the document belongs
/// to the EDT, and reading a length and then a range out of a document another thread is
/// editing is two reads of something that can change in between. Clamped offsets would not
/// rescue it either, since the buffer underneath can be restructured mid-read. The snapshot
/// is immutable and is what the rest of this bridge already uses to answer the platform
/// across that boundary; a value one edit out of date is the correct trade against a crash
/// inside somebody else's autofill query.
static android.view.autofill.AutofillValue editorAutofillValue() {
// Read the state AFTER the guards and confirm the session did not move under it.
// The three fields are assigned separately on the EDT, so taking the state first
// and validating afterwards can pair one field's text with the next field's
// configuration -- and the pairing that matters is a password field's text with a
// code field's hint. One session snapshot would express this better than three
// fields and a re-check, but that is the whole input bridge's shape rather than
// this method's, and the property needed here is only that nothing is returned
// for a session other than the one that was checked.
//
// Gated the same way the write path is, and for a sharper reason: between the EDT
// moving to another field and the UI thread taking the hint off the view, the
// surface still looks like a code field over a session that is something else --
// and answering this query then would hand that field's text to an SMS autofill
// service. The field after a code field is as likely to be a password as anything.
com.codename1.ui.TextInputClient client = activeInputClient;
if (client == null || editorAutofillHints() == null) {
return null;
}
com.codename1.ui.TextInputState state = activeInputState;
if (state == null || client != activeInputClient) {
return null;
}
String text = state.getText();
return android.view.autofill.AutofillValue.forText(text == null ? "" : text);
}

private static void configureEditorInfo(android.view.inputmethod.EditorInfo editorInfo, com.codename1.ui.TextInputConfig cfg) {
int constraint = cfg == null ? 0 : cfg.getConstraint();
int inputType;
Expand Down Expand Up @@ -450,6 +611,10 @@ private static void configureEditorInfo(android.view.inputmethod.EditorInfo edit
inputType |= android.text.InputType.TYPE_TEXT_FLAG_CAP_SENTENCES;
}
}
if ((constraint & com.codename1.ui.TextArea.ONE_TIME_CODE) != 0 && text) {
// a code is not a word: prediction would offer completions for it and, worse, learn it
inputType |= android.text.InputType.TYPE_TEXT_FLAG_NO_SUGGESTIONS;
}
editorInfo.inputType = inputType;
editorInfo.imeOptions = android.view.inputmethod.EditorInfo.IME_FLAG_NO_EXTRACT_UI;
if (multiline) {
Expand Down Expand Up @@ -521,6 +686,7 @@ public void run() {
imm.restartInput(v);
imm.showSoftInput(v, android.view.inputmethod.InputMethodManager.SHOW_IMPLICIT);
}
updateEditorAutofill(v, true);
}
});
return client;
Expand Down Expand Up @@ -579,6 +745,7 @@ public void run() {
imm.hideSoftInputFromWindow(view.getAndroidView().getWindowToken(), 0);
imm.restartInput(view.getAndroidView());
}
updateEditorAutofill(view.getAndroidView(), false);
}
});
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -223,6 +223,31 @@ public InputConnection onCreateInputConnection(EditorInfo editorInfo) {
return super.onCreateInputConnection(editorInfo);
}

/// The platform's autofill, when a pure editor holds the input session. The rendering surface
/// is the view an autofill service sees while a field is being edited (see
/// `AndroidImplementation#updateEditorAutofill(android.view.View, boolean)`), so the value it
/// offers -- a one-time code out of an arriving SMS -- arrives here.
@Override
public void autofill(android.view.autofill.AutofillValue value) {
if (!AndroidImplementation.autofillEditor(value)) {
super.autofill(value);
}
}

@Override
public int getAutofillType() {
if (AndroidImplementation.hasActiveInputClient()) {
return AUTOFILL_TYPE_TEXT;
}
return super.getAutofillType();
}

@Override
public android.view.autofill.AutofillValue getAutofillValue() {
android.view.autofill.AutofillValue v = AndroidImplementation.editorAutofillValue();
return v != null ? v : super.getAutofillValue();
}

@Override
public boolean onCheckIsTextEditor() {
if (AndroidImplementation.hasActiveInputClient()) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -225,6 +225,31 @@ public InputConnection onCreateInputConnection(EditorInfo editorInfo) {
return super.onCreateInputConnection(editorInfo);
}

/// The platform's autofill, when a pure editor holds the input session. The rendering surface
/// is the view an autofill service sees while a field is being edited (see
/// `AndroidImplementation#updateEditorAutofill(android.view.View, boolean)`), so the value it
/// offers -- a one-time code out of an arriving SMS -- arrives here.
@Override
public void autofill(android.view.autofill.AutofillValue value) {
if (!AndroidImplementation.autofillEditor(value)) {
super.autofill(value);
}
}

@Override
public int getAutofillType() {
if (AndroidImplementation.hasActiveInputClient()) {
return AUTOFILL_TYPE_TEXT;
}
return super.getAutofillType();
}

@Override
public android.view.autofill.AutofillValue getAutofillValue() {
android.view.autofill.AutofillValue v = AndroidImplementation.editorAutofillValue();
return v != null ? v : super.getAutofillValue();
}

@Override
public boolean onCheckIsTextEditor() {
if (AndroidImplementation.hasActiveInputClient()) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -1129,10 +1129,11 @@ public void setInputType(EditorInfo editorInfo) {
editorInfo.imeOptions |= EditorInfo.IME_ACTION_NONE;
}
int inputType = 0;
int constraint = txt.getConstraint();
if ((constraint & TextArea.PASSWORD) == TextArea.PASSWORD) {
constraint = constraint ^ TextArea.PASSWORD;
}
// The base type only. PASSWORD was already being stripped by hand here, which
// is the same intent applied to one modifier out of six: every other bit above
// the base -- SENSITIVE, NON_PREDICTIVE, USERNAME, UPPERCASE, ONE_TIME_CODE --
// fell through to the text keyboard and took the numeric one with it.
int constraint = txt.getConstraint() & 0xffff;
switch (constraint) {
case TextArea.NUMERIC:
inputType = EditorInfo.TYPE_CLASS_NUMBER | EditorInfo.TYPE_NUMBER_FLAG_SIGNED;
Expand Down
Loading
Loading