diff --git a/CodenameOne/src/com/codename1/components/OtpField.java b/CodenameOne/src/com/codename1/components/OtpField.java index c4ed0a9e558..5c255c9e026 100644 --- a/CodenameOne/src/com/codename1/components/OtpField.java +++ b/CodenameOne/src/com/codename1/components/OtpField.java @@ -23,47 +23,70 @@ package com.codename1.components; import com.codename1.ui.Container; +import com.codename1.ui.EditField; +import com.codename1.ui.Graphics; +import com.codename1.ui.TextArea; import com.codename1.ui.TextField; +import com.codename1.ui.TextInputConfig; +import com.codename1.ui.TextInputState; import com.codename1.ui.events.ActionEvent; import com.codename1.ui.events.ActionListener; import com.codename1.ui.events.DataChangedListener; -import com.codename1.ui.layouts.BoxLayout; +import com.codename1.ui.geom.Dimension; +import com.codename1.ui.layouts.FlowLayout; +import com.codename1.ui.layouts.GridLayout; +import com.codename1.ui.layouts.LayeredLayout; +import com.codename1.ui.plaf.Border; import java.util.ArrayList; -/// Segmented one-time-password input -- one box per digit, auto-advances to -/// the next box on input and steps back on backspace. Standard pattern for -/// SMS / authenticator code entry screens. +/// Segmented one-time-code input -- one box per digit, with a caret that walks +/// from box to box as the code is typed. The standard entry screen for an SMS +/// or authenticator code, and the second half of phone number verification. /// /// #### Example /// /// ```java /// OtpField otp = new OtpField(6); -/// otp.addCompleteListener(new ActionListener() { -/// public void actionPerformed(ActionEvent evt) { -/// String code = otp.getText(); -/// // verify code... -/// } -/// }); +/// otp.addCompleteListener(e -> verify(otp.getText())); /// form.add(otp); /// ``` /// -/// Style the individual boxes with the UIID "OtpDigit"; the field itself uses -/// "OtpField". +/// #### Receiving the code from the SMS +/// +/// The field carries `TextArea#ONE_TIME_CODE`, so the platform offers the code +/// from the incoming message by itself: on iOS the keyboard's suggestion bar +/// shows it above the keys, on Android the autofill service offers it on the +/// field. Accepting it fills every box at once. The application reads no +/// messages and asks for no messaging permission -- it only says what the field +/// is for, and the platform does the rest. A platform that cannot offer the +/// code is unaffected, and the code is typed. +/// +/// This is why the boxes are drawn rather than being separate editors. A code +/// arrives as one value, and a row of one-character fields can only receive one +/// character of it. Behind the boxes is a single field holding the whole code, +/// so an offered code, a paste and a keyboard all land the same way. +/// +/// #### Styling +/// +/// Each box uses the UIID "OtpDigit" and the field itself uses "OtpField". public class OtpField extends Container { private final int length; private final boolean numericOnly; private final TextField[] boxes; + private final OtpInput input; private final ArrayList completeListeners = new ArrayList(); - private boolean updating; + /// The value the completion listener last fired for, so the same full value does not + /// fire twice and a different one does. + private String completedValue; - /// Builds a 6-digit numeric OTP field -- the common case. + /// Builds a 6-digit numeric field -- the common case. public OtpField() { this(6, true); } - /// Builds an OTP field of the given length, numeric only. + /// Builds a field of the given length, numeric only. /// /// #### Parameters /// @@ -79,9 +102,9 @@ public OtpField(int length) { /// - `length`: number of digits / characters /// /// - `numericOnly`: true to restrict input to digits; false to allow any - /// character (alphanumeric OTP codes are sometimes used) + /// character (alphanumeric codes are sometimes used) public OtpField(int length, boolean numericOnly) { - super(BoxLayout.x()); + super(new LayeredLayout()); if (length < 2 || length > 16) { throw new IllegalArgumentException("OTP length must be between 2 and 16"); } @@ -89,135 +112,215 @@ public OtpField(int length, boolean numericOnly) { this.numericOnly = numericOnly; this.boxes = new TextField[length]; setUIID("OtpField"); - buildBoxes(); - } - - private void buildBoxes() { + Container row = new DigitRow(length); + row.setUIID("Container"); for (int i = 0; i < length; i++) { - final int index = i; - final TextField tf = new TextField(); + TextField tf = new TextField(); tf.setUIID("OtpDigit"); tf.setColumns(1); tf.setMaxSize(1); tf.setSingleLineTextArea(true); - if (numericOnly) { - tf.setConstraint(TextField.NUMERIC); - } - tf.addDataChangedListener(new DataChangedListener() { - @Override - public void dataChanged(int type, int idx) { - if (updating) { - return; - } - handleChange(index, tf); - } - }); + // display only: the value lives in the field underneath, and a box that + // took focus would tear the single editing session into one per box + tf.setEditable(false); + tf.setFocusable(false); + tf.getAllStyles().setAlignment(CENTER); boxes[i] = tf; - add(tf); + row.add(tf); } + add(FlowLayout.encloseCenter(row)); + input = new OtpInput(this); + add(input); + input.addDataChangedListener(new DataChangedListener() { + @Override + public void dataChanged(int type, int index) { + valueChanged(); + } + }); } - private void handleChange(int index, TextField source) { - String text = source.getText(); - if (text == null) { - text = ""; + /// Keeps the boxes showing the value and fires completion on the edit that + /// fills the last one. + private void valueChanged() { + String text = input.getText(); + for (int i = 0; i < length; i++) { + boxes[i].setText(i < text.length() ? text.substring(i, i + 1) : ""); } - // If multiple chars were pasted, distribute across boxes. - if (text.length() > 1) { - distributePaste(index, text); + if (input.isProvisional()) { + // The boxes show what is being composed -- an IME, handwriting or dictation + // builds text before committing it -- but a provisional value is not an + // answer. Firing here submits a code the input method is still editing, and + // the flow that acts on it is busy by the time the real one arrives. The + // completion flag is deliberately left alone, so finalizing fires it. return; } - if (text.length() == 1) { - // advance focus to next box if not last - if (index < length - 1) { - boxes[index + 1].startEditingAsync(); - } else { - fireCompleteIfFull(); - } - } else { - // empty -- step back to previous box on backspace - if (index > 0) { - boxes[index - 1].startEditingAsync(); + // Fired for a value, not for a transition. A full field that becomes a DIFFERENT + // full field in one edit -- an offered code accepted over a wrong one that was + // typed, a select-all paste, a second setText -- is a new attempt and the flow + // that submits from here has to hear about it; a boolean "already complete" would + // swallow exactly the case the platform's own offer produces. + boolean full = text.length() == length; + if (!full) { + completedValue = null; + return; + } + if (text.equals(completedValue)) { + return; + } + completedValue = text; + ActionEvent evt = new ActionEvent(this); + for (ActionListener listener : new ArrayList(completeListeners)) { + listener.actionPerformed(evt); + if (evt.isConsumed()) { + break; } } } - private void distributePaste(int startIndex, String text) { - updating = true; - try { - int p = startIndex; - for (int i = 0; i < text.length() && p < length; i++) { - char c = text.charAt(i); - if (numericOnly && (c < '0' || c > '9')) { - continue; - } - boxes[p].setText(String.valueOf(c)); - p++; + /// Drops everything this field will not accept: characters outside the + /// allowed set, and anything past the last box. Applied to typing, paste, + /// dictation and a code offered by the platform alike, because all four + /// arrive through the same path. + String accept(String text, int room) { + if (text == null || room <= 0) { + return ""; + } + StringBuilder b = new StringBuilder(text.length()); + for (int i = 0; i < text.length() && b.length() < room; i++) { + char c = text.charAt(i); + if (numericOnly && (c < '0' || c > '9')) { + continue; } - // clear any remaining cells past where we wrote - if (p > startIndex) { - // last cell to focus is the one after the last written, or - // the last box if we wrote to the end - int focus = p < length ? p : length - 1; - boxes[focus].startEditingAsync(); + if (c == '\n' || c == '\r' || c == '\t') { + continue; } - } finally { - updating = false; + b.append(c); } - fireCompleteIfFull(); + return b.toString(); } - private void fireCompleteIfFull() { - String code = getText(); - if (code.length() == length) { - ActionEvent evt = new ActionEvent(this); - for (ActionListener listener : completeListeners) { - listener.actionPerformed(evt); - if (evt.isConsumed()) { - break; - } - } + /// The box the caret sits in, clamped to the last box once the code is full. + TextField caretBox(int caretOffset) { + int i = caretOffset; + if (i >= length) { + i = length - 1; + } + if (i < 0) { + i = 0; } + return boxes[i]; } - /// Returns the current value, in order from the first box to the last. - /// Empty boxes are omitted, so a partial entry returns a shorter string. - public String getText() { - StringBuilder b = new StringBuilder(length); + /// The row of boxes, pinned left to right. + /// + /// A code is a sequence of digits, and digits read left to right in every + /// locale -- the platforms' own code fields do not mirror them. `BoxLayout` + /// lays its children out in reverse when its parent is right to left, which + /// would put the first digit on the right and show the whole code backwards + /// on a Hebrew or Arabic form, so this row opts out of that. Everything that + /// walks the boxes by position -- the caret, the hit test -- can then take + /// their order as given. + /// + /// Re-applied after `initLaf`, which is where the look and feel assigns the + /// flag: setting it once in the constructor would not survive being added to + /// a form, let alone a theme change. + /// + /// A grid rather than a box for a reason of its own. A box row hands each + /// child its preferred width and, when it runs out of room, gives what is + /// left of it to one child and zero to every child after that -- so a long + /// code on a narrow screen loses its last boxes entirely, while the field + /// still expects those characters and offers nowhere to tap to fix them. A + /// grid divides whatever width it has between the columns, so the boxes get + /// narrower and all of them stay. + private static final class DigitRow extends Container { + DigitRow(int length) { + super(new GridLayout(1, length)); + setRTL(false); + } + + @Override + protected void initLaf(com.codename1.ui.plaf.UIManager uim) { + super.initLaf(uim); + setRTL(false); + } + } + + /// The box a tap at this absolute x landed on, or the count of boxes when it + /// landed past the last one. Absolute rather than local because pointer + /// coordinates arrive in form space. + /// + /// The boxes are in left-to-right order whatever the form's direction, which + /// is what `DigitRow` is for, so this walks them in order. + /// + /// #### Parameters + /// + /// - `absX`: the absolute x of the pointer + /// + /// #### Returns + /// + /// the box index, from 0, or the length when the tap was past the last box + int boxIndexAt(int absX) { for (int i = 0; i < length; i++) { - String t = boxes[i].getText(); - if (t != null) { - b.append(t); + if (absX < boxes[i].getAbsoluteX() + boxes[i].getWidth()) { + return i; } } - return b.toString(); + return length; + } + + /// True once the caret has run past the last box, i.e. the code is full and + /// the caret belongs at the trailing edge rather than in front of a digit. + boolean caretPastEnd(int caretOffset) { + return caretOffset >= length; } - /// Sets the value, distributing one character per box. Excess characters - /// are silently dropped; shorter strings leave the remaining boxes empty. + /// Returns the current value, in order from the first box to the last. A + /// partial entry returns a shorter string. + public String getText() { + return input.getText(); + } + + /// Sets the value, one character per box. Excess characters are dropped, as + /// are characters this field does not accept; a shorter string leaves the + /// remaining boxes empty. + /// + /// #### Parameters + /// + /// - `code`: the value, or null to clear public void setText(String code) { - updating = true; - try { - for (int i = 0; i < length; i++) { - if (code != null && i < code.length()) { - boxes[i].setText(String.valueOf(code.charAt(i))); - } else { - boxes[i].setText(""); - } - } - } finally { - updating = false; - } + input.clearProvisional(); + String accepted = accept(code, length); + input.setText(accepted); + // setText resets the caret to the start; entry continues after the last + // character that was set, which is where the next one belongs + input.moveCaret(accepted.length(), false); + valueChanged(); } - /// Clears all boxes. + /// Clears every box and puts the caret back in the first one, ready for a + /// fresh code. public void clear() { setText(""); - boxes[0].startEditingAsync(); + startEditing(); + } + + /// Focuses the field and opens the keyboard, so a verification screen can + /// put the user straight into the code without a tap. + public void startEditing() { + input.requestFocus(); } - /// Adds a listener fired when the field becomes completely filled. Useful - /// to trigger automatic verification. + /// True when every box holds a character. + public boolean isComplete() { + return input.getText().length() == length; + } + + /// Adds a listener fired on the edit that fills the last box. Useful to + /// verify the code without a submit button. + /// + /// #### Parameters + /// + /// - `l`: the listener public void addCompleteListener(ActionListener l) { if (l != null) { completeListeners.add(l); @@ -225,18 +328,264 @@ public void addCompleteListener(ActionListener l) { } /// Removes a previously-registered listener. + /// + /// #### Parameters + /// + /// - `l`: the listener public void removeCompleteListener(ActionListener l) { completeListeners.remove(l); } - /// Returns the underlying [TextField] for the box at `index`. Useful for - /// custom theming / focus management. + /// Adds a listener fired on every change to the value, not only on the one + /// that completes it. + /// + /// #### Parameters + /// + /// - `l`: the listener + public void addDataChangedListener(DataChangedListener l) { + input.addDataChangedListener(l); + } + + /// Removes a previously-registered listener. + /// + /// #### Parameters + /// + /// - `l`: the listener + public void removeDataChangedListener(DataChangedListener l) { + input.removeDataChangedListener(l); + } + + /// Returns the box at `index`, which displays the character at that + /// position. Useful for theming an individual box; the value itself is read + /// and written through `#getText()` / `#setText(String)`, since a code is + /// entered into the field as a whole rather than box by box. + /// + /// #### Parameters + /// + /// - `index`: the box position, from 0 + /// + /// #### Returns + /// + /// the box at that position public TextField getBox(int index) { return boxes[index]; } + /// The field that actually holds the code and carries the one-time-code + /// hint. It spans the boxes and draws only the caret. Exposed for the cases + /// the boxes cannot serve: adding a done listener, or reading the caret. + public EditField getInputField() { + return input; + } + /// Returns the configured length (number of boxes). public int getLength() { return length; } + + /// True when the field accepts digits only. + public boolean isNumericOnly() { + return numericOnly; + } + + /// The field that actually holds the code. It spans the boxes, draws + /// nothing but the caret, and carries the one-time-code hint that lets the + /// platform offer the code from the incoming message. + private static final class OtpInput extends EditField { + + private final OtpField owner; + private boolean caretOn = true; + private long lastBlink; + private boolean provisional; + + OtpInput(OtpField owner) { + super(""); + this.owner = owner; + setUIID("OtpFieldInput"); + // no pixels of its own: the boxes underneath are the field's appearance + getAllStyles().setBgTransparency(0); + getAllStyles().setBorder(Border.createEmpty()); + getAllStyles().setPadding(0, 0, 0, 0); + getAllStyles().setMargin(0, 0, 0, 0); + setConstraint((owner.numericOnly ? TextArea.NUMERIC : TextArea.ANY) | TextArea.ONE_TIME_CODE); + } + + /// Correction and capitalization off, whatever the field's other settings. + /// + /// A code is not language. The platform would otherwise capitalize the first + /// letter of an alphanumeric code and offer to correct the rest, and it does that + /// BEFORE the value reaches this field -- so the user types the code they were + /// sent, the keyboard changes it, and the server rejects a code that was correct + /// when it left their hands. A digits-only field is safe from this by virtue of + /// its keyboard; one that accepts letters is not, and the flags cost nothing + /// either way. + @Override + public TextInputConfig getConfig() { + return super.getConfig().setAutoCorrect(false).setAutoCapitalize(false); + } + + @Override + protected boolean handleTypedText(String text) { + String accepted = limit(text); + if (accepted.equals(text)) { + return false; + } + if (accepted.length() > 0) { + insertText(accepted); + } + return true; + } + + /// Text arriving from a platform input source, which is the path a committed + /// autocorrection, a pasted value, dictation and an offered code all take. + /// + /// Filtered here as well as in `#handleTypedText(String)` because the two do not + /// meet: a commit that finalizes an IME composition replaces the composed range + /// directly and never reaches the typed-text hook. An unfiltered value there would + /// leave the field holding something it would refuse from the keyboard -- letters in + /// a numeric code, or more characters than there are boxes -- which shows as a code + /// that can never be complete and a verification that never fires. + @Override + public void commitText(String text) { + // A commit is the input method's final answer. + // + // It also ends the composition, which is worth stating because it was + // questioned: the commit replaces the composed range through the editor's + // ordinary document-change path, and that path clears the composing range. So + // a second commit follows the first rather than replacing it, and an input + // method that delivers a code in fragments builds it up correctly. Verified + // rather than assumed -- EditorViewInputTest holds it. + provisional = false; + super.commitText(limit(text)); + owner.valueChanged(); + } + + /// The in-progress composition an IME, handwriting or dictation builds before it + /// commits. It writes to the document directly, so it needs the same limit; without + /// it the field can hold an unacceptable value for as long as the composition lasts, + /// and dictation into a code field is composition from the first syllable. + @Override + public void setComposingText(String text, int relativeCaret) { + provisional = true; + super.setComposingText(limit(text), relativeCaret); + } + + /// A platform editing a range of the document directly, which is how iOS + /// delivers an edit through UITextInput -- the bridge calls this rather than + /// committing text, so it reaches neither the typed-text hook nor the commit. + /// The fourth door into this field, and the last one: a range replacement could + /// otherwise put letters in a numeric code or more characters than there are + /// boxes, which shows as a code that can never be complete. + @Override + public void replaceRange(int start, int end, String text) { + // Final, not provisional. iOS reaches here for an edit that ends its marked + // text -- the view clears its own marked range and sends this with no + // finishComposing behind it -- so a provisional flag left standing would + // suppress completion for the rest of the session. + provisional = false; + int replaced = Math.abs(end - start); + int used = getText().length() - replaced; + super.replaceRange(start, end, owner.accept(text, owner.length - used)); + } + + /// The end of a composition with no commit behind it, which is a final answer + /// too. It changes no text, so nothing else would tell the field to look again. + @Override + public void finishComposing() { + provisional = false; + super.finishComposing(); + owner.valueChanged(); + } + + /// True while an input method is still building the value. + /// + /// A property of the delivery rather than of the document: what this needs to + /// know is whether the text that just arrived was provisional, which is answered + /// by which method delivered it. Reading the editor's composing range would + /// answer the same question today by depending on when that range happens to be + /// set and cleared around each notification, which is more than this needs to + /// rely on. + boolean isProvisional() { + return provisional; + } + + void clearProvisional() { + provisional = false; + } + + /// Where a tap puts the caret. The inherited hit test measures the field's own + /// text, laid out from its left edge -- a rendering that exists in the metrics + /// and nowhere on the screen, because this component draws boxes instead and + /// this layer draws nothing. Answering from it puts the caret nowhere near the + /// box the user aimed at, so a correction lands on the wrong digit. + /// + /// Clamped to the text, because a tap on an empty box means the end of what has + /// been entered rather than an offset past it -- and the caller assigns this + /// offset to the caret without clamping it itself. + @Override + public int offsetAtPoint(int absX, int absY) { + return Math.min(owner.boxIndexAt(absX), getText().length()); + } + + /// Drops what this field will not take: characters outside the allowed set, and + /// anything beyond the last box once the range this text replaces is accounted for. + private String limit(String text) { + TextInputState state = getEditingState(); + int replacedStart = state.getComposingStart(); + int replacedEnd = state.getComposingEnd(); + if (replacedStart < 0 || replacedEnd <= replacedStart) { + replacedStart = getSelectionStart(); + replacedEnd = getSelectionEnd(); + } + int used = getText().length() - (replacedEnd - replacedStart); + return owner.accept(text, owner.length - used); + } + + @Override + protected Dimension calcPreferredSize() { + // the boxes decide how big the field is; this layer only overlays them + return new Dimension(0, 0); + } + + @Override + public void paint(Graphics g) { + if (!caretOn || !hasFocus() || !isEditableState()) { + return; + } + int caret = getCaretOffset(); + TextField box = owner.caretBox(caret); + // The caret belongs to a box that is a cousin rather than a child, so its position + // has to cross coordinate spaces. Painting happens with the ancestors' offsets + // already applied to the Graphics -- which is why every component here draws at its + // own getX() rather than its absolute position -- so an absolute coordinate would + // add those offsets a second time and land the caret somewhere else, or outside the + // clip and nowhere at all. The difference between this component's absolute and + // local origin is exactly the translation in force, so subtracting it puts the box + // in the space this Graphics is drawing in. + int dx = getAbsoluteX() - getX(); + int dy = getAbsoluteY() - getY(); + int h = box.getHeight() / 2; + int w = Math.max(1, box.getWidth() / 16); + int boxX = box.getAbsoluteX() - dx; + int x = owner.caretPastEnd(caret) + ? boxX + box.getWidth() - box.getStyle().getPaddingRight(isRTL()) - w + : boxX + (box.getWidth() - w) / 2; + g.setColor(box.getStyle().getFgColor()); + g.fillRect(x, box.getAbsoluteY() - dy + (box.getHeight() - h) / 2, w, h); + } + + @Override + public boolean animate() { + boolean sup = super.animate(); + if (hasFocus()) { + long now = System.currentTimeMillis(); + if (now - lastBlink >= 500) { + caretOn = !caretOn; + lastBlink = now; + return true; + } + } + return sup; + } + } } diff --git a/CodenameOne/src/com/codename1/components/PhoneNumberField.java b/CodenameOne/src/com/codename1/components/PhoneNumberField.java new file mode 100644 index 00000000000..659e03f4f4e --- /dev/null +++ b/CodenameOne/src/com/codename1/components/PhoneNumberField.java @@ -0,0 +1,744 @@ +/* + * Copyright (c) 2008-2026, Codename One and/or its affiliates. All rights reserved. + * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER. + * This code is free software; you can redistribute it and/or modify it + * under the terms of the GNU General Public License version 2 only, as + * published by the Free Software Foundation. Codename One designates this + * particular file as subject to the "Classpath" exception as provided + * by Oracle in the LICENSE file that accompanied this code. + * + * This code is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License + * version 2 for more details (a copy is included in the LICENSE file that + * accompanied this code). + * + * You should have received a copy of the GNU General Public License version + * 2 along with this work; if not, write to the Free Software Foundation, + * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA. + * + * Please contact Codename One through http://www.codenameone.com/ if you + * need additional information or have any questions. + */ +package com.codename1.components; + +import com.codename1.l10n.L10NManager; +import com.codename1.ui.Button; +import com.codename1.ui.Container; +import com.codename1.ui.Dialog; +import com.codename1.ui.TextArea; +import com.codename1.ui.TextField; +import com.codename1.ui.events.ActionEvent; +import com.codename1.ui.events.ActionListener; +import com.codename1.ui.events.DataChangedListener; +import com.codename1.ui.layouts.BorderLayout; +import com.codename1.ui.layouts.BoxLayout; + +import java.util.ArrayList; +import java.util.List; + +/// A phone number entry field: a country selector holding the calling code and +/// a field for the rest of the number, producing one E.164 string. +/// +/// #### Example +/// +/// ```java +/// PhoneNumberField phone = new PhoneNumberField(); +/// form.add(phone); +/// sendButton.addActionListener(e -> requestCode(phone.getE164())); +/// ``` +/// +/// The selector starts on the country the device is in and opens a searchable +/// list of every calling code. The number field carries +/// `TextArea#PHONENUMBER`, so it gets the phone keypad and the platform offers +/// the device's own number where it knows it. +/// +/// #### The value +/// +/// `#getE164()` returns the number in the one format a server can act on -- +/// a leading "+", the calling code, then the national number, digits only: +/// +/// Everything that is not a digit is dropped, so the separators a user reaches +/// for make no difference: +/// +/// ```java +/// phone.setCountry(PhoneNumberField.findCountry("IL")); +/// // user types 50-123-4567, or 50 123 4567, or (50) 1234567 +/// phone.getE164(); // "+972501234567" +/// ``` +/// +/// A number that already carries its own calling code is used as it stands, and +/// the selector is not applied to it. Pasting is one way that happens; platform +/// autofill is the other, since it offers the device's own number in exactly +/// that form: +/// +/// ```java +/// // Israel selected, the user pastes +1 415 555 0100 +/// phone.getE164(); // "+14155550100", not the selection with that appended +/// ``` +/// +/// A national trunk prefix is a digit, and it is kept: +/// +/// ```java +/// // the same user typing the number the way they say it out loud +/// // user types 050-123-4567 +/// phone.getE164(); // "+9720501234567" -- the leading 0 is still there +/// ``` +/// +/// That is not an oversight, and it is the one thing to handle before sending. +/// "0" is a trunk prefix in Israel and part of the number in Italy, and telling +/// them apart is a per-country rule this field does not carry, so stripping one +/// here would corrupt numbers in the countries where it belongs. Normalizing is +/// left to the service that sends the message, which has the rules and can +/// refuse what it cannot make sense of. +/// +/// What this field does carry is the shape of E.164 -- at most fifteen digits, +/// and the calling code separated from the rest -- so `#isValid()` is a sanity +/// check rather than a verdict. +/// +/// #### Country names +/// +/// Names are English, and each is looked up first as "Country." plus the ISO +/// code in the theme's resource bundle, so an application that ships +/// translations gets them without replacing the list. An application with its +/// own list entirely passes it to `#setCountries(Country[])`. +/// +/// #### Styling +/// +/// The field uses the UIID "PhoneNumberField", the country selector +/// "PhoneNumberCountry" and the number field "PhoneNumberText". +public class PhoneNumberField extends Container { + + /// A country and its E.164 calling code. + public static final class Country { + + private final String isoCode; + private final String dialCode; + private final String name; + + /// Builds a country entry. + /// + /// #### Parameters + /// + /// - `isoCode`: the two letter ISO 3166 code, e.g. "IL" + /// + /// - `dialCode`: the calling code without the "+", e.g. "972" + /// + /// - `name`: the display name + public Country(String isoCode, String dialCode, String name) { + this.isoCode = isoCode; + this.dialCode = dialCode; + this.name = name; + } + + /// The two letter ISO 3166 code. + public String getIsoCode() { + return isoCode; + } + + /// The calling code, digits only, without the "+". + public String getDialCode() { + return dialCode; + } + + /// The English display name. + public String getName() { + return name; + } + + @Override + public String toString() { + return name + " +" + dialCode; + } + + @Override + public boolean equals(Object o) { + if (this == o) { + return true; + } + if (!(o instanceof Country)) { + return false; + } + return isoCode.equals(((Country) o).isoCode); + } + + @Override + public int hashCode() { + return isoCode.hashCode(); + } + } + + /// ISO code, calling code and English name for every region with a numbering + /// plan of its own, ordered by name. Parsed once, on first use. + /// + /// Generated from libphonenumber's region metadata rather than written out by + /// hand, and that is the point: two hundred and forty-five calling codes typed + /// from memory would contain mistakes nobody would find until somebody in that + /// country could not sign in. + /// + /// It follows that eight ISO 3166 codes are absent -- AN, AQ, BV, GS, HM, PN, TF + /// and UM -- and their absence is correct rather than an oversight. One of them + /// stopped being a country in 2010; most of the rest have no permanent + /// population; and none of them has a numbering plan of its own, which is why + /// the metadata carries none. Pitcairn's numbers, for instance, are reachable + /// through New Zealand's +64 rather than through anything assigned to PN. + /// + /// Do not add one by hand. A calling code invented for a territory is exactly + /// the error this table is generated to avoid, and adding the one that gets + /// noticed while leaving the seven that do not is worse than either. An + /// application that serves such a place passes its own list to + /// `#setCountries(Country[])`, which is what that method is for. + private static final String COUNTRY_TABLE = + "AF|93|Afghanistan;AL|355|Albania;DZ|213|Algeria;AS|1|American Samoa;AD|376|Andorra;" + + "AO|244|Angola;AI|1|Anguilla;AG|1|Antigua & Barbuda;AR|54|Argentina;AM|374|Armenia;AW|297|Aruba;" + + "AC|247|Ascension Island;AU|61|Australia;AT|43|Austria;AZ|994|Azerbaijan;BS|1|Bahamas;" + + "BH|973|Bahrain;BD|880|Bangladesh;BB|1|Barbados;BY|375|Belarus;BE|32|Belgium;BZ|501|Belize;" + + "BJ|229|Benin;BM|1|Bermuda;BT|975|Bhutan;BO|591|Bolivia;BA|387|Bosnia & Herzegovina;" + + "BW|267|Botswana;BR|55|Brazil;IO|246|British Indian Ocean Territory;VG|1|British Virgin Islands;" + + "BN|673|Brunei;BG|359|Bulgaria;BF|226|Burkina Faso;BI|257|Burundi;KH|855|Cambodia;" + + "CM|237|Cameroon;CA|1|Canada;CV|238|Cape Verde;BQ|599|Caribbean Netherlands;KY|1|Cayman Islands;" + + "CF|236|Central African Republic;TD|235|Chad;CL|56|Chile;CN|86|China;CX|61|Christmas Island;" + + "CC|61|Cocos (Keeling) Islands;CO|57|Colombia;KM|269|Comoros;CG|242|Congo - Brazzaville;" + + "CD|243|Congo - Kinshasa;CK|682|Cook Islands;CR|506|Costa Rica;HR|385|Croatia;CU|53|Cuba;" + + "CW|599|Cura\u00e7ao;CY|357|Cyprus;CZ|420|Czechia;CI|225|C\u00f4te d'Ivoire;DK|45|Denmark;" + + "DJ|253|Djibouti;DM|1|Dominica;DO|1|Dominican Republic;EC|593|Ecuador;EG|20|Egypt;" + + "SV|503|El Salvador;GQ|240|Equatorial Guinea;ER|291|Eritrea;EE|372|Estonia;SZ|268|Eswatini;" + + "ET|251|Ethiopia;FK|500|Falkland Islands;FO|298|Faroe Islands;FJ|679|Fiji;FI|358|Finland;" + + "FR|33|France;GF|594|French Guiana;PF|689|French Polynesia;GA|241|Gabon;GM|220|Gambia;" + + "GE|995|Georgia;DE|49|Germany;GH|233|Ghana;GI|350|Gibraltar;GR|30|Greece;GL|299|Greenland;" + + "GD|1|Grenada;GP|590|Guadeloupe;GU|1|Guam;GT|502|Guatemala;GG|44|Guernsey;GN|224|Guinea;" + + "GW|245|Guinea-Bissau;GY|592|Guyana;HT|509|Haiti;HN|504|Honduras;HK|852|Hong Kong SAR China;" + + "HU|36|Hungary;IS|354|Iceland;IN|91|India;ID|62|Indonesia;IR|98|Iran;IQ|964|Iraq;IE|353|Ireland;" + + "IM|44|Isle of Man;IL|972|Israel;IT|39|Italy;JM|1|Jamaica;JP|81|Japan;JE|44|Jersey;" + + "JO|962|Jordan;KZ|7|Kazakhstan;KE|254|Kenya;KI|686|Kiribati;XK|383|Kosovo;KW|965|Kuwait;" + + "KG|996|Kyrgyzstan;LA|856|Laos;LV|371|Latvia;LB|961|Lebanon;LS|266|Lesotho;LR|231|Liberia;" + + "LY|218|Libya;LI|423|Liechtenstein;LT|370|Lithuania;LU|352|Luxembourg;MO|853|Macao SAR China;" + + "MG|261|Madagascar;MW|265|Malawi;MY|60|Malaysia;MV|960|Maldives;ML|223|Mali;MT|356|Malta;" + + "MH|692|Marshall Islands;MQ|596|Martinique;MR|222|Mauritania;MU|230|Mauritius;YT|262|Mayotte;" + + "MX|52|Mexico;FM|691|Micronesia;MD|373|Moldova;MC|377|Monaco;MN|976|Mongolia;ME|382|Montenegro;" + + "MS|1|Montserrat;MA|212|Morocco;MZ|258|Mozambique;MM|95|Myanmar (Burma);NA|264|Namibia;" + + "NR|674|Nauru;NP|977|Nepal;NL|31|Netherlands;NC|687|New Caledonia;NZ|64|New Zealand;" + + "NI|505|Nicaragua;NE|227|Niger;NG|234|Nigeria;NU|683|Niue;NF|672|Norfolk Island;" + + "KP|850|North Korea;MK|389|North Macedonia;MP|1|Northern Mariana Islands;NO|47|Norway;" + + "OM|968|Oman;PK|92|Pakistan;PW|680|Palau;PS|970|Palestinian Territories;PA|507|Panama;" + + "PG|675|Papua New Guinea;PY|595|Paraguay;PE|51|Peru;PH|63|Philippines;PL|48|Poland;" + + "PT|351|Portugal;PR|1|Puerto Rico;QA|974|Qatar;RO|40|Romania;RU|7|Russia;RW|250|Rwanda;" + + "RE|262|R\u00e9union;WS|685|Samoa;SM|378|San Marino;SA|966|Saudi Arabia;SN|221|Senegal;" + + "RS|381|Serbia;SC|248|Seychelles;SL|232|Sierra Leone;SG|65|Singapore;SX|1|Sint Maarten;" + + "SK|421|Slovakia;SI|386|Slovenia;SB|677|Solomon Islands;SO|252|Somalia;ZA|27|South Africa;" + + "KR|82|South Korea;SS|211|South Sudan;ES|34|Spain;LK|94|Sri Lanka;BL|590|St. Barth\u00e9lemy;" + + "SH|290|St. Helena;KN|1|St. Kitts & Nevis;LC|1|St. Lucia;MF|590|St. Martin;" + + "PM|508|St. Pierre & Miquelon;VC|1|St. Vincent & Grenadines;SD|249|Sudan;SR|597|Suriname;" + + "SJ|47|Svalbard & Jan Mayen;SE|46|Sweden;CH|41|Switzerland;SY|963|Syria;" + + "ST|239|S\u00e3o Tom\u00e9 & Pr\u00edncipe;TW|886|Taiwan;TJ|992|Tajikistan;TZ|255|Tanzania;" + + "TH|66|Thailand;TL|670|Timor-Leste;TG|228|Togo;TK|690|Tokelau;TO|676|Tonga;" + + "TT|1|Trinidad & Tobago;TA|290|Tristan da Cunha;TN|216|Tunisia;TR|90|Turkey;TM|993|Turkmenistan;" + + "TC|1|Turks & Caicos Islands;TV|688|Tuvalu;VI|1|U.S. Virgin Islands;UG|256|Uganda;" + + "UA|380|Ukraine;AE|971|United Arab Emirates;GB|44|United Kingdom;US|1|United States;" + + "UY|598|Uruguay;UZ|998|Uzbekistan;VU|678|Vanuatu;VA|39|Vatican City;VE|58|Venezuela;" + + "VN|84|Vietnam;WF|681|Wallis & Futuna;EH|212|Western Sahara;YE|967|Yemen;ZM|260|Zambia;" + + "ZW|263|Zimbabwe;AX|358|\u00c5land Islands"; + + private static Country[] allCountries; + + private final Button countryButton = new Button(); + private final TextField number = new TextField(); + private Country[] countries; + private Country country; + + /// Builds a field defaulting to the country the device reports, falling + /// back to the first entry when the device reports one that is not in the + /// list. + public PhoneNumberField() { + super(new BorderLayout()); + setUIID("PhoneNumberField"); + countryButton.setUIID("PhoneNumberCountry"); + number.setUIID("PhoneNumberText"); + number.setConstraint(TextArea.PHONENUMBER); + number.setHint(getUIManager().localize("PhoneNumberField.Hint", "Phone number")); + countryButton.addActionListener(new ActionListener() { + @Override + public void actionPerformed(ActionEvent evt) { + showCountryPicker(); + } + }); + add(BorderLayout.WEST, countryButton); + add(BorderLayout.CENTER, number); + setCountry(defaultCountry()); + } + + /// The list this field offers, defaulting to every known country. + /// + /// #### Returns + /// + /// the countries offered by the selector + public Country[] getCountries() { + // a copy either way: the full list is a single shared table, and handing + // it out is handing out every field's list at once + Country[] source = countries == null ? allCountries() : countries; + Country[] copy = new Country[source.length]; + System.arraycopy(source, 0, copy, 0, source.length); + return copy; + } + + /// Narrows or replaces the list this field offers. An application serving + /// three countries has no reason to show two hundred. + /// + /// #### Parameters + /// + /// - `countries`: the countries to offer, or null to restore the full list + public void setCountries(Country[] countries) { + if (countries == null) { + this.countries = null; + // The selection has to come from the list on offer, and a country that was + // only in a replaced list is not in this one. Not by taking the first entry + // of the full table, which is Afghanistan and has nothing to do with anyone: + // silently moving a user from +1 to +93 is worse than the inconsistency it + // tidies. The same code is preferred where the full list has it, and the + // device's own country is the fallback -- which is where the field started. + Country listed = findCountry(country.getIsoCode()); + setCountry(listed != null ? listed : defaultCountry()); + return; + } + if (countries.length == 0) { + throw new IllegalArgumentException("At least one country is required"); + } + this.countries = new Country[countries.length]; + System.arraycopy(countries, 0, this.countries, 0, countries.length); + // The entry from the new list, not merely the knowledge that one matches. + // Countries are equal when their ISO codes are, so a list can carry a different + // object for the same country -- the built-in United States beside an + // application's own -- and keeping the old one would leave the field dialling a + // code the selector no longer offers. Where nothing matches, the first entry of + // the list the application supplied is a defensible default in a way the first + // entry of the full table is not. + Country listed = null; + for (Country candidate : countries) { + if (candidate.equals(country)) { + listed = candidate; + break; + } + } + setCountry(listed != null ? listed : countries[0]); + } + + /// The selected country, never null. + public Country getCountry() { + return country; + } + + /// Selects a country, which changes the calling code the value is built + /// from without touching the number that was typed. + /// + /// The country has to be one this field offers. Selecting one that is not + /// leaves the selector showing a country the list it opens does not contain, + /// and the field submitting a calling code the user was never given the + /// chance to choose -- a mistake worth hearing about where it is made rather + /// than in a support ticket about numbers from the wrong country. + /// + /// The object itself is kept rather than replaced by the equal one from the + /// list. Countries are equal by ISO code, so an application that supplies its + /// own entry for a country -- a different name, or a calling code it has + /// reason to override -- keeps what it passed. + /// + /// #### Parameters + /// + /// - `c`: the country; ignored when null + /// + /// #### Throws + /// + /// - `IllegalArgumentException`: when the country is not one this field + /// offers; narrow or widen the list with `#setCountries(Country[])` first + public void setCountry(Country c) { + if (c == null) { + return; + } + if (!isOffered(c)) { + throw new IllegalArgumentException("Country " + c.getIsoCode() + + " is not one this field offers; pass it to setCountries first"); + } + country = c; + countryButton.setText("+" + c.getDialCode()); + } + + /// True when the country is one the selector would list. + private boolean isOffered(Country c) { + Country[] list = countries == null ? allCountries() : countries; + for (Country candidate : list) { + if (candidate.equals(c)) { + return true; + } + } + return false; + } + + /// The national part as typed, digits only. + public String getNationalNumber() { + String digits = digitsOf(number.getText()); + if (!isInternational()) { + return digits; + } + // The value already says which country it is for, so the national part is what + // follows that country's calling code rather than the whole thing. Resolved + // against the full table rather than the offered list: the number means what it + // means whichever countries this field happens to be offering. + Country match = longestMatch(allCountries(), digits); + return match == null ? digits : digits.substring(match.getDialCode().length()); + } + + /// True when what has been typed is an international number rather than a national + /// one -- pasted, or handed over by the platform, which offers the device's own + /// number in exactly that form. + private boolean isInternational() { + String raw = number.getText(); + if (raw == null) { + return false; + } + for (int i = 0; i < raw.length(); i++) { + char c = raw.charAt(i); + if (c == '+') { + return true; + } + if (c != ' ' && c != '\t') { + return false; + } + } + return false; + } + + /// The country in `list` whose calling code the digits start with, longest first, + /// or null when none does. + private static Country longestMatch(Country[] list, String digits) { + Country match = null; + int matchLength = 0; + for (Country candidate : list) { + String dial = candidate.getDialCode(); + if (digits.length() > dial.length() && digits.startsWith(dial) + && dial.length() > matchLength) { + match = candidate; + matchLength = dial.length(); + } + } + return match; + } + + /// The number in E.164 form -- "+", the calling code, then the national + /// number -- or null when nothing has been typed. + /// + /// #### Returns + /// + /// the E.164 number, or null when the national part is empty + public String getE164() { + if (isInternational()) { + // Used as typed. A value that already carries a calling code is a whole + // number, and prepending the selector's code to it would produce one that is + // neither the one that was pasted nor the one the selector shows -- pasting + // +972501234567 with Israel selected once produced +972972501234567. + String digits = digitsOf(number.getText()); + return digits.length() == 0 ? null : "+" + digits; + } + String national = getNationalNumber(); + if (national.length() == 0) { + return null; + } + return "+" + country.getDialCode() + national; + } + + /// Sets the field from an E.164 number, selecting the country whose calling + /// code the number starts with and putting the rest in the number field. + /// + /// Several countries share a calling code (+1 covers the United States, + /// Canada and much of the Caribbean, which the North American area code + /// tells apart and this field does not), and the number alone does not say + /// which. The currently selected country is kept when its code matches, and + /// otherwise the first country listed for that code is selected. + /// + /// #### Parameters + /// + /// - `e164`: the number, with or without the leading "+"; null clears the + /// field + public void setE164(String e164) { + if (e164 == null) { + number.setText(""); + return; + } + String digits = digitsOf(e164); + Country[] list = getCountries(); + Country match = null; + int matchLength = 0; + // Longest calling code wins. Assigned country codes are prefix-free, so + // at most one of them can match and the length never decides anything -- + // but a list an application supplies is under no such discipline, and a + // shorter code would otherwise swallow a longer one's numbers. + for (Country candidate : list) { + String dial = candidate.getDialCode(); + if (digits.length() > dial.length() && digits.startsWith(dial)) { + if (dial.length() > matchLength || (dial.length() == matchLength && candidate.equals(country))) { + match = candidate; + matchLength = dial.length(); + } + } + } + if (match == null) { + // No country here can express it -- a narrowed list, or a calling code the + // table does not carry. Kept in international form so it survives unchanged + // rather than being read back as the selected country's code followed by all + // of it, which is a different number and a plausible looking one. + number.setText("+" + digits); + return; + } + setCountry(match); + number.setText(digits.substring(matchLength)); + } + + /// A sanity check on the shape of the number: a national part that is + /// present and short enough to leave the whole number inside E.164's + /// fifteen digit limit. It is not a check that the number exists, which + /// only the service that sends the message can answer. + /// + /// #### Returns + /// + /// true when the number could be an E.164 number + public boolean isValid() { + String e164 = getE164(); + if (e164 == null) { + return false; + } + // A calling code never starts with zero, so a number typed in international form + // that does is not one. The same rule as PhoneVerification.isPlausibleE164, which + // is the check the flow makes before sending. It applies only here: a leading zero + // in a NATIONAL number is a trunk prefix, which this field keeps on purpose. + if (e164.length() > 1 && e164.charAt(1) == '0') { + return false; + } + // measured against what getE164 would actually send, which is not the selector's + // code plus the field when the field holds a whole number of its own + return getNationalNumber().length() >= 4 && e164.length() - 1 <= 15; + } + + /// The field holding the national part, exposed for theming and for + /// listening to what is typed. + public TextField getNumberField() { + return number; + } + + /// The button that opens the country list, exposed for theming. + public Button getCountryButton() { + return countryButton; + } + + /// Adds a listener notified as the number is typed. + /// + /// #### Parameters + /// + /// - `l`: the listener + public void addDataChangedListener(DataChangedListener l) { + number.addDataChangedListener(l); + } + + /// Removes a previously-registered listener. + /// + /// #### Parameters + /// + /// - `l`: the listener + public void removeDataChangedListener(DataChangedListener l) { + number.removeDataChangedListener(l); + } + + private void showCountryPicker() { + final Dialog dlg = new Dialog(getUIManager().localize("PhoneNumberField.CountryTitle", "Country")); + dlg.setLayout(new BorderLayout()); + final Container list = new Container(BoxLayout.y()); + list.setScrollableY(true); + final Country[] offered = getCountries(); + final TextField search = new TextField("", + getUIManager().localize("PhoneNumberField.Search", "Search"), 20, TextArea.ANY); + search.addDataChangedListener(new DataChangedListener() { + @Override + public void dataChanged(int type, int index) { + fillCountryList(list, offered, search.getText(), dlg); + list.animateLayout(100); + } + }); + fillCountryList(list, offered, "", dlg); + dlg.add(BorderLayout.NORTH, search); + dlg.add(BorderLayout.CENTER, list); + dlg.show(); + } + + private void fillCountryList(Container list, Country[] offered, String filter, final Dialog dlg) { + list.removeAll(); + String needle = foldCase(filter); + for (final Country c : offered) { + String label = displayName(c); + if (!matchesSearch(label, c, needle)) { + continue; + } + MultiButton entry = new MultiButton(label); + entry.setTextLine2("+" + c.getDialCode()); + entry.addActionListener(new ActionListener() { + @Override + public void actionPerformed(ActionEvent evt) { + setCountry(c); + dlg.dispose(); + } + }); + list.add(entry); + } + } + + /// True when a country should be offered for what has been typed into the + /// search field. The needle must already be folded. + /// + /// #### Parameters + /// + /// - `label`: the name being shown for the country + /// + /// - `c`: the country + /// + /// - `needle`: the folded search text, empty to match everything + /// + /// #### Returns + /// + /// true when the country matches + static boolean matchesSearch(String label, Country c, String needle) { + if (needle.length() == 0) { + return true; + } + return foldCase(label).indexOf(needle) >= 0 + || c.getDialCode().indexOf(needle) >= 0 + || foldCase(c.getIsoCode()).indexOf(needle) >= 0; + } + + /// Lower cases without asking the device what that means. + /// + /// `String.toLowerCase` folds with the default locale, and the two sides of a + /// search do not survive that: on a Turkish device the capital I of "Israel" + /// becomes a dotless i while the i the user typed stays dotted, so the country + /// cannot be found by typing its first letter. Folding character by character + /// uses the Unicode mapping instead, which is the same everywhere -- and unlike + /// an ASCII-only fold it still matches an accented name by its accented letter. + /// + /// #### Parameters + /// + /// - `s`: the text to fold, or null + /// + /// #### Returns + /// + /// the folded text, empty for null + static String foldCase(String s) { + if (s == null) { + return ""; + } + StringBuilder b = new StringBuilder(s.length()); + for (int i = 0; i < s.length(); i++) { + b.append(Character.toLowerCase(s.charAt(i))); + } + return b.toString(); + } + + /// The name shown for a country: its English name unless the theme's + /// resource bundle translates "Country." plus its ISO code. + private String displayName(Country c) { + return getUIManager().localize("Country." + c.getIsoCode(), c.getName()); + } + + private Country defaultCountry() { + Country[] list = getCountries(); + String iso = L10NManager.getInstance().getLocale(); + if (iso != null) { + // The locale is an ISO 3166 country code, but a device that reports + // a full locale ("en_US") still names the country in its tail. + // Compared without folding either side, for the reason findCountry gives. + String tail = iso; + int separator = Math.max(iso.lastIndexOf('_'), iso.lastIndexOf('-')); + if (separator >= 0) { + tail = iso.substring(separator + 1); + } + for (Country candidate : list) { + if (candidate.getIsoCode().equalsIgnoreCase(tail)) { + return candidate; + } + } + } + // A device reporting a region the list does not carry -- one of the eight + // without a numbering plan, or a country the application has narrowed away -- + // gets the first entry. Any choice here is arbitrary; this one is at least + // stable, and the user's own first act on this screen is to pick a country. + return list[0]; + } + + private static String digitsOf(String s) { + if (s == null) { + return ""; + } + StringBuilder b = new StringBuilder(s.length()); + for (int i = 0; i < s.length(); i++) { + char c = s.charAt(i); + if (c >= '0' && c <= '9') { + b.append(c); + } + } + return b.toString(); + } + + /// Every country with a numbering plan of its own, ordered by English name. + /// + /// A handful of ISO 3166 regions are deliberately absent -- see the note on the + /// table itself -- because they have no calling code assigned to them. An + /// application that needs one supplies its own list. + public static Country[] getAllCountries() { + Country[] all = allCountries(); + Country[] copy = new Country[all.length]; + System.arraycopy(all, 0, copy, 0, all.length); + return copy; + } + + /// Looks a country up by its two letter ISO 3166 code. + /// + /// #### Parameters + /// + /// - `isoCode`: the code, case insensitive + /// + /// #### Returns + /// + /// the country, or null when the code is not one this list carries + public static Country findCountry(String isoCode) { + if (isoCode == null) { + return null; + } + Country[] all = allCountries(); + for (Country candidate : all) { + // equalsIgnoreCase rather than folding the argument: String.toUpperCase folds + // with the device's locale, and a Turkish device turns "il" into a dotted + // capital I that matches no ISO 3166 code -- so this documented case + // insensitive lookup would find nothing at all there. Character-wise case + // comparison carries no locale. + if (candidate.getIsoCode().equalsIgnoreCase(isoCode)) { + return candidate; + } + } + return null; + } + + private static synchronized Country[] allCountries() { + if (allCountries == null) { + List parsed = new ArrayList(256); + int start = 0; + while (start < COUNTRY_TABLE.length()) { + int end = COUNTRY_TABLE.indexOf(';', start); + if (end < 0) { + end = COUNTRY_TABLE.length(); + } + String record = COUNTRY_TABLE.substring(start, end); + int a = record.indexOf('|'); + int b = record.indexOf('|', a + 1); + parsed.add(new Country(record.substring(0, a), + record.substring(a + 1, b), record.substring(b + 1))); + start = end + 1; + } + allCountries = parsed.toArray(new Country[parsed.size()]); + } + return allCountries; + } +} diff --git a/CodenameOne/src/com/codename1/components/PhoneVerification.java b/CodenameOne/src/com/codename1/components/PhoneVerification.java new file mode 100644 index 00000000000..9bbf605d152 --- /dev/null +++ b/CodenameOne/src/com/codename1/components/PhoneVerification.java @@ -0,0 +1,669 @@ +/* + * Copyright (c) 2008-2026, Codename One and/or its affiliates. All rights reserved. + * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER. + * This code is free software; you can redistribute it and/or modify it + * under the terms of the GNU General Public License version 2 only, as + * published by the Free Software Foundation. Codename One designates this + * particular file as subject to the "Classpath" exception as provided + * by Oracle in the LICENSE file that accompanied this code. + * + * This code is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License + * version 2 for more details (a copy is included in the LICENSE file that + * accompanied this code). + * + * You should have received a copy of the GNU General Public License version + * 2 along with this work; if not, write to the Free Software Foundation, + * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA. + * + * Please contact Codename One through http://www.codenameone.com/ if you + * need additional information or have any questions. + */ +package com.codename1.components; + +import com.codename1.ui.Button; +import com.codename1.ui.CN; +import com.codename1.ui.Container; +import com.codename1.ui.Label; +import com.codename1.ui.events.ActionEvent; +import com.codename1.ui.events.ActionListener; +import com.codename1.ui.layouts.BoxLayout; +import com.codename1.ui.util.EventDispatcher; +import com.codename1.ui.util.UITimer; + +/// The two stages of verifying that a user holds a phone number: enter the +/// number, then enter the code that arrives by SMS. +/// +/// The application supplies both server calls. This component owns everything +/// around them: the number entry, the code entry, the wait before a resend is +/// offered, the way back to a mistyped number, and the errors either call +/// reports. +/// +/// #### Example +/// +/// ```java +/// PhoneVerification verify = new PhoneVerification(); +/// verify.setCodeSender((number, response) -> myServer.sendSms(number, response)); +/// verify.setCodeVerifier((number, code, response) -> myServer.check(number, code, response)); +/// verify.addVerifiedListener(e -> showMainScreen()); +/// form.add(verify); +/// ``` +/// +/// A sender is handed the number and a `Response`, and calls exactly one of +/// `Response#succeeded()` or `Response#failed(String)` when its server answers +/// -- from any thread. Until then the button it came from stays disabled, so a +/// second tap cannot send a second message. +/// +/// #### The code is offered by the platform +/// +/// The code field is an `OtpField`, so it carries the one-time-code hint and +/// the platform offers the arriving code on the keyboard or through autofill. +/// Nothing here reads messages, and no messaging permission is involved. +/// +/// #### Styling +/// +/// The component uses the UIID "PhoneVerification", its explanatory lines +/// "PhoneVerificationText", its error line "PhoneVerificationError" and its +/// buttons "PhoneVerificationButton" -- except the resend and change-number +/// buttons, which use "PhoneVerificationLink". +public class PhoneVerification extends Container { + + /// The application's answer to one request. Exactly one of its methods + /// takes effect, and later calls are ignored rather than rejected: a server + /// wrapper that answers twice on a retry is a nuisance, not a reason to + /// leave the screen stuck. + /// + /// Either method may be called from any thread. + public static final class Response { + + private final PhoneVerification owner; + private final int generation; + private final boolean sending; + private boolean answered; + + Response(PhoneVerification owner, int generation, boolean sending) { + this.owner = owner; + this.generation = generation; + this.sending = sending; + } + + /// The request succeeded: the message was sent, or the code was + /// correct. + public void succeeded() { + deliver(true, null); + } + + /// The request failed. + /// + /// #### Parameters + /// + /// - `message`: what to show the user; a default is shown when null + public void failed(String message) { + deliver(false, message); + } + + private void deliver(final boolean ok, final String message) { + synchronized (this) { + if (answered) { + return; + } + answered = true; + } + CN.callSerially(new Runnable() { + @Override + public void run() { + owner.requestAnswered(generation, sending, ok, message); + } + }); + } + } + + /// Asks the application's server to send a code to a number. + public interface CodeSender { + /// Sends the code. + /// + /// #### Parameters + /// + /// - `e164Number`: the number in E.164 form, e.g. "+972501234567" + /// + /// - `response`: answered when the server replies + void sendCode(String e164Number, Response response); + } + + /// Asks the application's server whether a code matches a number. + public interface CodeVerifier { + /// Verifies the code. + /// + /// #### Parameters + /// + /// - `e164Number`: the number the code was sent to + /// + /// - `code`: the code the user entered + /// + /// - `response`: answered when the server replies + void verifyCode(String e164Number, String code, Response response); + } + + private final PhoneNumberField phone = new PhoneNumberField(); + private final OtpField code; + private final Label sentTo = new Label(); + private final Label error = new Label(); + private final Button send = new Button(); + private final Button verify = new Button(); + private final Button resend = new Button(); + private final Button changeNumber = new Button(); + private final Container numberStage = new Container(BoxLayout.y()); + private final Container codeStage = new Container(BoxLayout.y()); + private final EventDispatcher verifiedListeners = new EventDispatcher(); + private final EventDispatcher failedListeners = new EventDispatcher(); + + private CodeSender codeSender; + private CodeVerifier codeVerifier; + private String number; + /// The number a send is out for, which becomes `number` only if it succeeds. + private String pendingNumber; + private int resendDelay = 60; + private int resendRemaining; + private UITimer resendTimer; + private int generation; + private boolean busy; + /// Set when the code stage was entered before there was a form to focus into -- + /// the documented way to start at the second stage does exactly that -- so the + /// keyboard can be opened once the component is attached instead of not at all. + private boolean focusCodeWhenAttached; + + /// Builds the flow with a six digit code. + public PhoneVerification() { + this(6); + } + + /// Builds the flow with a code of the given length. + /// + /// #### Parameters + /// + /// - `codeLength`: the number of digits in the code + public PhoneVerification(int codeLength) { + super(BoxLayout.y()); + setUIID("PhoneVerification"); + code = new OtpField(codeLength); + sentTo.setUIID("PhoneVerificationText"); + error.setUIID("PhoneVerificationError"); + error.setVisible(false); + send.setUIID("PhoneVerificationButton"); + verify.setUIID("PhoneVerificationButton"); + resend.setUIID("PhoneVerificationLink"); + changeNumber.setUIID("PhoneVerificationLink"); + send.setText(localize("PhoneVerification.Send", "Send code")); + verify.setText(localize("PhoneVerification.Verify", "Verify")); + changeNumber.setText(localize("PhoneVerification.ChangeNumber", "Change number")); + + send.addActionListener(new ActionListener() { + @Override + public void actionPerformed(ActionEvent evt) { + requestCode(phone.getE164()); + } + }); + verify.addActionListener(new ActionListener() { + @Override + public void actionPerformed(ActionEvent evt) { + submitCode(); + } + }); + resend.addActionListener(new ActionListener() { + @Override + public void actionPerformed(ActionEvent evt) { + requestCode(number); + } + }); + changeNumber.addActionListener(new ActionListener() { + @Override + public void actionPerformed(ActionEvent evt) { + showNumberStage(); + } + }); + code.addCompleteListener(new ActionListener() { + @Override + public void actionPerformed(ActionEvent evt) { + // a complete code is the user saying they are done; asking them + // to press a button as well is a step no verification screen has + submitCode(); + } + }); + + numberStage.add(phone).add(send); + codeStage.add(sentTo).add(code).add(verify).add(resend).add(changeNumber); + add(error); + showNumberStage(); + } + + private String localize(String key, String defaultValue) { + return getUIManager().localize(key, defaultValue); + } + + // ---- stages ---- + + /// Returns to the first stage, with the number as it was left, and clears + /// any code that was typed. + public void showNumberStage() { + abandonPendingRequest(); + stopResendTimer(); + code.setText(""); + replaceStage(numberStage); + setError(null); + setBusy(false); + } + + /// Moves to the code stage for a number, as though the code had just been + /// sent. Useful when the application sent the message itself rather than + /// through `#setCodeSender(CodeSender)`. + /// + /// #### Parameters + /// + /// - `e164Number`: the number the code went to + public void showCodeStage(String e164Number) { + abandonPendingRequest(); + number = e164Number; + phone.setE164(e164Number); + sentTo.setText(localize("PhoneVerification.SentTo", "Code sent to") + " " + e164Number); + code.setText(""); + replaceStage(codeStage); + setError(null); + setBusy(false); + startResendTimer(); + focusCodeWhenAttached = getComponentForm() == null; + if (!focusCodeWhenAttached) { + code.startEditing(); + } + } + + /// Retires whatever request is still out. A stage transition is the user + /// saying the request they were waiting for no longer describes the screen: + /// they backed out of a verification, or changed a number a code was being + /// sent to. Without this the answer still matches the current generation + /// when it lands, so a late success would report a number as verified after + /// the user left that screen, or drag them back to a code stage they had + /// just abandoned. + /// + /// Answers already delivered are unaffected -- this only invalidates one + /// that has not arrived yet. + private void abandonPendingRequest() { + // busy is left to the caller: both transitions set it through setBusy so + // the buttons follow, and a second owner of that flag would be one too many + generation++; + } + + private void replaceStage(Container stage) { + if (getComponentCount() > 1 && getComponentAt(1) == stage) { //NOPMD CompareObjectsWithEquals + return; + } + if (getComponentCount() > 1) { + removeComponent(getComponentAt(1)); + } + add(stage); + if (isInitialized()) { + animateLayout(150); + } + } + + /// True when the code stage is showing. + public boolean isCodeStage() { + return getComponentCount() > 1 && getComponentAt(1) == codeStage; //NOPMD CompareObjectsWithEquals + } + + // ---- requests ---- + + /// Sends a code to a number, moving to the code stage when the server + /// accepts it. Called by the send and resend buttons; an application driving + /// the flow from its own button calls it directly. + /// + /// #### Parameters + /// + /// - `e164Number`: the number to send to + public void requestCode(String e164Number) { + if (busy) { + return; + } + if (!isPlausibleE164(e164Number)) { + setError(localize("PhoneVerification.BadNumber", "Enter a valid phone number")); + return; + } + if (codeSender == null) { + setError(localize("PhoneVerification.NoSender", "No verification service is configured")); + return; + } + // Held, not committed. The number a code was sent to only becomes this + // component's number when the server says it sent one: a custom layout can call + // this from the code stage with a different number, and if that send is refused + // the screen still describes the number that worked -- so the code the user is + // looking at must still be verified against that one, and getPhoneNumber must + // still name it. + pendingNumber = e164Number; + setError(null); + setBusy(true); + generation++; + codeSender.sendCode(e164Number, new Response(this, generation, true)); + } + + /// The shape a number must have before a request is worth making: a "+", + /// then between five and fifteen digits, which is what E.164 allows. It is + /// not a check that the number exists -- that is the sending service's + /// answer, and its refusal is shown to the user like any other failure. + /// + /// #### Parameters + /// + /// - `e164Number`: the number to check + /// + /// #### Returns + /// + /// true when the number is worth sending to + public static boolean isPlausibleE164(String e164Number) { + if (e164Number == null || !e164Number.startsWith("+")) { + return false; + } + int digits = 0; + for (int i = 1; i < e164Number.length(); i++) { + char c = e164Number.charAt(i); + if (c < '0' || c > '9') { + return false; + } + // A calling code never starts with zero -- E.164 reserves that digit -- so a + // number that does is not one, however many digits follow it. + if (digits == 0 && c == '0') { + return false; + } + digits++; + } + return digits >= 5 && digits <= 15; + } + + /// Verifies the code currently entered. Called when the last box is filled + /// and by the verify button. + public void submitCode() { + if (busy || !isCodeStage()) { + return; + } + if (!code.isComplete()) { + setError(localize("PhoneVerification.ShortCode", "Enter the whole code")); + return; + } + if (codeVerifier == null) { + setError(localize("PhoneVerification.NoVerifier", "No verification service is configured")); + return; + } + setError(null); + setBusy(true); + generation++; + codeVerifier.verifyCode(number, code.getText(), new Response(this, generation, false)); + } + + /// Delivered on the EDT by `Response`. A response from a request that has + /// since been superseded -- the user went back and sent to another number + /// while the first server call was still out -- is dropped, because acting + /// on it would move a screen the user has already left. + void requestAnswered(int forGeneration, boolean sending, boolean ok, String message) { + if (forGeneration != generation) { + return; + } + setBusy(false); + if (ok) { + if (sending) { + // and now it is the number, because a code was sent to it + showCodeStage(pendingNumber); + } else { + fireVerified(); + } + return; + } + setError(message != null ? message + : sending ? localize("PhoneVerification.SendFailed", "The code could not be sent") + : localize("PhoneVerification.WrongCode", "That code is not correct")); + if (!sending) { + code.clear(); + } + failedListeners.fireActionEvent(new ActionEvent(this)); + } + + private void fireVerified() { + verifiedListeners.fireActionEvent(new ActionEvent(this)); + } + + private void setBusy(boolean busy) { + this.busy = busy; + send.setEnabled(!busy); + verify.setEnabled(!busy); + resend.setEnabled(!busy && resendRemaining <= 0); + } + + private void setError(String message) { + error.setText(message == null ? "" : message); + error.setVisible(message != null); + if (isInitialized()) { + revalidateLater(); + } + } + + // ---- resend countdown ---- + + private void startResendTimer() { + stopResendTimer(); + resendRemaining = resendDelay; + updateResendLabel(); + // no form yet means the countdown starts when the component is added; + // initComponent arms it then + if (resendRemaining <= 0 || getComponentForm() == null) { + return; + } + resendTimer = UITimer.timer(1000, true, getComponentForm(), new Runnable() { + @Override + public void run() { + tickResend(); + } + }); + } + + private void tickResend() { + if (resendRemaining > 0) { + resendRemaining--; + updateResendLabel(); + if (resendRemaining <= 0) { + stopResendTimer(); + } + } + } + + private void updateResendLabel() { + if (resendRemaining > 0) { + resend.setEnabled(false); + resend.setText(localize("PhoneVerification.ResendIn", "Resend in") + + " " + resendRemaining); + } else { + resend.setEnabled(!busy); + resend.setText(localize("PhoneVerification.Resend", "Resend code")); + } + } + + private void stopResendTimer() { + if (resendTimer != null) { + resendTimer.cancel(); + resendTimer = null; + } + } + + @Override + protected void deinitialize() { + // a timer bound to a form that is going away would otherwise keep + // ticking against a screen nobody is looking at + stopResendTimer(); + super.deinitialize(); + } + + /* + * Leaving the form does NOT retire a pending request, and that asymmetry with + * showNumberStage is deliberate. + * + * A stage transition is the user saying they are done waiting for this answer -- + * they went back to fix the number. Deinitialization says nothing of the kind: it + * happens whenever the component stops being displayed, including when an + * application shows a progress screen over the wait, which is a reasonable thing + * to do and would then never hear the result. Silently losing a verification the + * server did answer is worse than the two things dropping it would prevent, and + * both of those are mild: a detached component moving to its code stage is the + * state it should be in when it is shown again, and a listener firing late belongs + * to the application, which can remove it if it has moved on. + * + * PhoneVerificationTest pins this, so that a later reading of the same evidence + * does not quietly reverse it. + */ + + @Override + protected void initComponent() { + super.initComponent(); + if (focusCodeWhenAttached && isCodeStage()) { + // Once, and only for a request that had nowhere to go. Focusing on every + // initComponent would take focus back and reopen the keyboard each time the + // user returned to this screen. Deferred a beat because the form settles its + // own initial focus as it is shown, and the later of the two wins. + focusCodeWhenAttached = false; + CN.callSerially(new Runnable() { + @Override + public void run() { + if (isCodeStage()) { + code.startEditing(); + } + } + }); + } + if (isCodeStage() && resendRemaining > 0 && resendTimer == null && getComponentForm() != null) { + resendTimer = UITimer.timer(1000, true, getComponentForm(), new Runnable() { + @Override + public void run() { + tickResend(); + } + }); + } + } + + // ---- configuration ---- + + /// Sets the server call that sends a code to a number. + /// + /// #### Parameters + /// + /// - `codeSender`: the sender + public void setCodeSender(CodeSender codeSender) { + this.codeSender = codeSender; + } + + /// Sets the server call that checks a code. + /// + /// #### Parameters + /// + /// - `codeVerifier`: the verifier + public void setCodeVerifier(CodeVerifier codeVerifier) { + this.codeVerifier = codeVerifier; + } + + /// The seconds the user waits before a resend is offered; 60 by default. + public int getResendDelay() { + return resendDelay; + } + + /// Sets the seconds before a resend is offered. Zero offers it at once. + /// + /// #### Parameters + /// + /// - `seconds`: the delay + public void setResendDelay(int seconds) { + this.resendDelay = Math.max(0, seconds); + if (resendRemaining > resendDelay) { + // A countdown already running is shortened to the new delay, so setting zero + // offers the resend now rather than at the next stage change. A LONGER delay + // is not applied to it: extending a wait somebody is already serving is not + // something a setter should do behind their back, and it takes effect on the + // next code like any other change. + resendRemaining = resendDelay; + updateResendLabel(); + if (resendRemaining <= 0) { + stopResendTimer(); + } + } + } + + /// The number the code was sent to, in E.164 form, or null before a code + /// has been requested. + public String getPhoneNumber() { + return number; + } + + /// The number entry field, exposed for theming and for narrowing the + /// country list. + public PhoneNumberField getPhoneNumberField() { + return phone; + } + + /// The code entry field, exposed for theming. + public OtpField getOtpField() { + return code; + } + + /// The button that sends the first code, exposed for theming and for + /// relabelling. + public Button getSendButton() { + return send; + } + + /// The button that submits a typed code, exposed for theming. The code is + /// also submitted as soon as the last box is filled. + public Button getVerifyButton() { + return verify; + } + + /// The button that asks for another code, exposed for theming. + public Button getResendButton() { + return resend; + } + + /// The button that returns to the number stage, exposed for theming. + public Button getChangeNumberButton() { + return changeNumber; + } + + /// Adds a listener fired when a code is accepted. + /// + /// #### Parameters + /// + /// - `l`: the listener + public void addVerifiedListener(ActionListener l) { + verifiedListeners.addListener(l); + } + + /// Removes a previously-registered listener. + /// + /// #### Parameters + /// + /// - `l`: the listener + public void removeVerifiedListener(ActionListener l) { + verifiedListeners.removeListener(l); + } + + /// Adds a listener fired when either server call reports a failure. The + /// failure is already shown to the user; this is for an application that + /// wants to count attempts or log them. + /// + /// #### Parameters + /// + /// - `l`: the listener + public void addFailedListener(ActionListener l) { + failedListeners.addListener(l); + } + + /// Removes a previously-registered listener. + /// + /// #### Parameters + /// + /// - `l`: the listener + public void removeFailedListener(ActionListener l) { + failedListeners.removeListener(l); + } +} diff --git a/CodenameOne/src/com/codename1/ui/TextArea.java b/CodenameOne/src/com/codename1/ui/TextArea.java index 355bdb28cfc..b55b504a45a 100644 --- a/CodenameOne/src/com/codename1/ui/TextArea.java +++ b/CodenameOne/src/com/codename1/ui/TextArea.java @@ -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; /// Indicates the enter key to be used for editing the text area and by the /// text field private static final char ENTER_KEY = '\n'; diff --git a/CodenameOne/src/com/codename1/ui/TextField.java b/CodenameOne/src/com/codename1/ui/TextField.java index 7c566af90fc..4864faef3cf 100644 --- a/CodenameOne/src/com/codename1/ui/TextField.java +++ b/CodenameOne/src/com/codename1/ui/TextField.java @@ -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) == ','; } diff --git a/Ports/Android/src/AndroidMaterialTheme.res b/Ports/Android/src/AndroidMaterialTheme.res index dc9bf64f231..2b8897c3e92 100644 Binary files a/Ports/Android/src/AndroidMaterialTheme.res and b/Ports/Android/src/AndroidMaterialTheme.res differ diff --git a/Ports/Android/src/com/codename1/impl/android/AndroidAsyncView.java b/Ports/Android/src/com/codename1/impl/android/AndroidAsyncView.java index 69ab44f5c1c..20ae24560b9 100644 --- a/Ports/Android/src/com/codename1/impl/android/AndroidAsyncView.java +++ b/Ports/Android/src/com/codename1/impl/android/AndroidAsyncView.java @@ -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()) { diff --git a/Ports/Android/src/com/codename1/impl/android/AndroidImplementation.java b/Ports/Android/src/com/codename1/impl/android/AndroidImplementation.java index 60cde2a8fb6..21660be5870 100644 --- a/Ports/Android/src/com/codename1/impl/android/AndroidImplementation.java +++ b/Ports/Android/src/com/codename1/impl/android/AndroidImplementation.java @@ -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()) { + 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); + } + } + + /// 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; @@ -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) { @@ -521,6 +686,7 @@ public void run() { imm.restartInput(v); imm.showSoftInput(v, android.view.inputmethod.InputMethodManager.SHOW_IMPLICIT); } + updateEditorAutofill(v, true); } }); return client; @@ -579,6 +745,7 @@ public void run() { imm.hideSoftInputFromWindow(view.getAndroidView().getWindowToken(), 0); imm.restartInput(view.getAndroidView()); } + updateEditorAutofill(view.getAndroidView(), false); } }); } diff --git a/Ports/Android/src/com/codename1/impl/android/AndroidSurfaceView.java b/Ports/Android/src/com/codename1/impl/android/AndroidSurfaceView.java index 74324fabaa0..d15704e54c4 100644 --- a/Ports/Android/src/com/codename1/impl/android/AndroidSurfaceView.java +++ b/Ports/Android/src/com/codename1/impl/android/AndroidSurfaceView.java @@ -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()) { diff --git a/Ports/Android/src/com/codename1/impl/android/AndroidTextureView.java b/Ports/Android/src/com/codename1/impl/android/AndroidTextureView.java index 48a29611f13..408d420f707 100644 --- a/Ports/Android/src/com/codename1/impl/android/AndroidTextureView.java +++ b/Ports/Android/src/com/codename1/impl/android/AndroidTextureView.java @@ -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()) { diff --git a/Ports/Android/src/com/codename1/impl/android/CodenameOneView.java b/Ports/Android/src/com/codename1/impl/android/CodenameOneView.java index 48ac5abadca..dc196d6c081 100644 --- a/Ports/Android/src/com/codename1/impl/android/CodenameOneView.java +++ b/Ports/Android/src/com/codename1/impl/android/CodenameOneView.java @@ -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; diff --git a/Ports/Android/src/com/codename1/impl/android/InPlaceEditView.java b/Ports/Android/src/com/codename1/impl/android/InPlaceEditView.java index f9bb4c141f5..34e40e61420 100644 --- a/Ports/Android/src/com/codename1/impl/android/InPlaceEditView.java +++ b/Ports/Android/src/com/codename1/impl/android/InPlaceEditView.java @@ -192,6 +192,79 @@ private void initInputTypeMap() { private boolean hasConstraint(int inputType, int constraint) { return ((inputType & constraint) == constraint); } + + /// The Android autofill hint for a one-time code. Spelled out rather than referenced as + /// `View.AUTOFILL_HINT_SMS_OTP`, which is newer than the SDK this port compiles against; the + /// string is the contract an autofill service matches on. + private static final String AUTOFILL_HINT_SMS_OTP = "smsOTPCode"; + + /// Whether the field this editor last opened was a one-time code, so that a change of + /// purpose on a reused native field can be told to the autofill framework and an + /// unchanged one can be left alone. + private boolean lastWasOneTimeCode; + + /// Tells the platform whether this field holds a code that arrived by message, which is what + /// makes an autofill service offer that code on it. A field without the hint is offered + /// nothing, and the application would be left reading SMS itself to fill it -- with the + /// permission that implies. Suggestions are turned off with it: a code is not a word, and + /// predictive input has no business learning one. + /// + /// Set AND cleared, because the native field outlives the Codename One field it is editing. + /// Tapping from one field straight into another reuses this same EditText through + /// switchToTextArea rather than building a new one, so a hint left behind by a code field + /// would still be on the view when the next field opens, and the platform would offer the + /// next arriving code to whatever the user tapped into. Clearing restores what a freshly + /// constructed EditText carries: no hints, and AUTO rather than NO -- an ordinary field is + /// autofillable, and turning that off here would stop a password manager filling the + /// username and password fields it is the whole point of. + /// + /// The input type needs no such undo: it is recomputed and assigned in full above rather + /// than amended, so the no-suggestions flag cannot survive into the next field. + /// + /// #### Parameters + /// + /// - `edit`: the native field being opened + /// + /// - `codenameOneInputType`: the Codename One constraint the field carries + private void updateOneTimeCodeHint(AutoCompleteTextView edit, int codenameOneInputType) { + boolean oneTimeCode = hasConstraint(codenameOneInputType, TextArea.ONE_TIME_CODE); + if (oneTimeCode) { + edit.setInputType(edit.getInputType() | InputType.TYPE_TEXT_FLAG_NO_SUGGESTIONS); + } + if (android.os.Build.VERSION.SDK_INT < 26) { + return; + } + if (oneTimeCode) { + edit.setImportantForAutofill(android.view.View.IMPORTANT_FOR_AUTOFILL_YES); + edit.setAutofillHints(new String[]{AUTOFILL_HINT_SMS_OTP}); + } else { + edit.setImportantForAutofill(android.view.View.IMPORTANT_FOR_AUTOFILL_AUTO); + edit.setAutofillHints((String[]) null); + } + // And tell the autofill framework, because setting the hints does not. + // + // The session is opened when the view is entered, which is the focus call further + // down this method -- before this point on a new field, and not at all on a reused + // one, since tapping from one Codename One field straight into another keeps the + // same EditText and merely re-points it. Either way the session was opened while + // the view described the previous field, so a code field would be offered nothing + // and the field after a code field would be offered the code. Leaving and + // re-entering re-opens it against what the view says now. + // + // Only when the purpose actually changed: re-entering on every field open would + // restart sessions that are working, which is how a password manager loses track + // of the pair it was filling. + if (lastWasOneTimeCode != oneTimeCode) { + android.view.autofill.AutofillManager afm = + (android.view.autofill.AutofillManager) edit.getContext() + .getSystemService(android.view.autofill.AutofillManager.class); + if (afm != null) { + afm.notifyViewExited(edit); + afm.notifyViewEntered(edit); + } + } + lastWasOneTimeCode = oneTimeCode; + } private boolean isNonPredictive(int inputType) { return hasConstraint(inputType, TextArea.NON_PREDICTIVE) || hasConstraint(inputType, TextArea.SENSITIVE); } @@ -998,6 +1071,8 @@ public boolean onActionItemClicked(ActionMode mode, mEditText.setTransformationMethod(new MyPasswordTransformationMethod()); } + updateOneTimeCodeHint(mEditText, codenameOneInputType); + int maxLength = textArea.maxSize; InputFilter[] FilterArray = new InputFilter[1]; FilterArray[0] = new InputFilter.LengthFilter(maxLength); diff --git a/Ports/JavaScriptPort/src/main/java/com/codename1/impl/html5/HTML5Implementation.java b/Ports/JavaScriptPort/src/main/java/com/codename1/impl/html5/HTML5Implementation.java index 23e919a4ef7..69b6ce58fce 100644 --- a/Ports/JavaScriptPort/src/main/java/com/codename1/impl/html5/HTML5Implementation.java +++ b/Ports/JavaScriptPort/src/main/java/com/codename1/impl/html5/HTML5Implementation.java @@ -3367,7 +3367,13 @@ private void configureLightweightTextInputElement() { if (config == null || lightweightTextInputElement == null) { return; } - lightweightTextInputElement.setAttribute("autocomplete", config.isAutoCorrect() ? "on" : "off"); + // ONE_TIME_CODE: "one-time-code" is what a browser and a password manager match on to + // offer the code out of an arriving message, and on iOS Safari it is what puts the code in + // the keyboard's suggestion bar. It is a value of autocomplete rather than a flag beside + // it, so it replaces the plain on/off the rest of the fields get. + boolean oneTimeCode = (config.getConstraint() & TextArea.ONE_TIME_CODE) != 0; + lightweightTextInputElement.setAttribute("autocomplete", + oneTimeCode ? "one-time-code" : config.isAutoCorrect() ? "on" : "off"); lightweightTextInputElement.setAttribute("autocorrect", config.isAutoCorrect() ? "on" : "off"); lightweightTextInputElement.setAttribute("spellcheck", config.isAutoCorrect() ? "true" : "false"); lightweightTextInputElement.setAttribute("autocapitalize", config.isAutoCapitalize() ? "sentences" : "off"); @@ -3389,6 +3395,11 @@ private void configureLightweightTextInputElement() { default: break; } + // Deliberately NOT forcing inputmode to numeric for a one-time code. The hint says what + // the value IS; the constraint beside it says how it is typed, and a code field that did + // not ask for NUMERIC can hold letters -- OtpField(length, false) exists for exactly + // that. A numeric inputmode gives a mobile browser a keypad with no route to a letter, + // which would make those codes impossible to enter rather than merely awkward. lightweightTextInputElement.setAttribute("inputmode", inputMode); switch (config.getActionType()) { case TextInputConfig.ACTION_DONE: @@ -6186,6 +6197,9 @@ static String applyTextInputConstraints(HTMLElement inputEl, TextArea ta, boolea inputMode = "url"; } } + // As in configureLightweightTextInputElement: ONE_TIME_CODE contributes the autocomplete + // token and nothing about the keyboard, so an alphanumeric code keeps a keyboard that can + // type one. if (inputMode == null) { inputEl.removeAttribute("inputmode"); } else { @@ -6196,6 +6210,11 @@ static String applyTextInputConstraints(HTMLElement inputEl, TextArea ta, boolea String autocomplete; if (override != null) { autocomplete = override.toString(); + } else if ((constraint & TextArea.ONE_TIME_CODE) != 0) { + // Ahead of every other case including SENSITIVE: a one-time code is exactly the value + // a browser should offer out of an arriving message, and it is worthless to a + // dictionary afterwards, so the token that invites the offer is the right one here. + autocomplete = "one-time-code"; } else if (sensitive) { // Checked ahead of the password case on purpose: SENSITIVE asks that the value is // never retained for predictive or completing schemes, and "current-password" is an diff --git a/Ports/iOSPort/nativeSources/CN1TextInputView.h b/Ports/iOSPort/nativeSources/CN1TextInputView.h index 34952db850f..88ccac36c50 100644 --- a/Ports/iOSPort/nativeSources/CN1TextInputView.h +++ b/Ports/iOSPort/nativeSources/CN1TextInputView.h @@ -40,6 +40,11 @@ @property (nonatomic) UITextSpellCheckingType spellCheckingType; @property (nonatomic, getter=isSecureTextEntry) BOOL secureTextEntry; @property (nonatomic) BOOL multiline; +/// What the field holds, when that is something the platform can offer to fill: a one-time +/// code from an arriving message, currently. Typed as NSString rather than UITextContentType +/// so the declaration carries no availability of its own -- the value is what is guarded, and +/// a deployment target older than the constant simply never assigns one. +@property (nonatomic, copy) NSString *textContentType; /// The TextInputConfig.ACTION_* code delivered through tiEditorAction when Return is pressed on a /// single line field. @property (nonatomic) int actionType; diff --git a/Ports/iOSPort/nativeSources/CN1TextInputView.m b/Ports/iOSPort/nativeSources/CN1TextInputView.m index 49e1ed89167..b6138d86e0a 100644 --- a/Ports/iOSPort/nativeSources/CN1TextInputView.m +++ b/Ports/iOSPort/nativeSources/CN1TextInputView.m @@ -172,6 +172,8 @@ - (void)dealloc { [_markedTextStyle release]; [_cn1Tokenizer release]; [_textInteraction release]; + // the port builds without ARC, so the copy property's ivar is ours to release + [_textContentType release]; [super dealloc]; } @@ -731,6 +733,23 @@ void com_codename1_impl_ios_IOSNative_startTextInput___int_boolean_boolean_boole default: break; } + // ONE_TIME_CODE: the field says it holds a code that arrived by message, which is + // what makes iOS offer that code above the keyboard. Cleared otherwise, because the + // view outlives one editing session and a stale content type would follow the caret + // into the next field. + if ((constraint & 0x1000000) != 0) { + if (@available(iOS 12, *)) { + cn1TextInputView.textContentType = UITextContentTypeOneTimeCode; + } else { + cn1TextInputView.textContentType = nil; + } + // a digit code belongs on the number pad; the offered code is shown above it + if (kt == UIKeyboardTypeNumbersAndPunctuation) { + kt = UIKeyboardTypeNumberPad; + } + } else { + cn1TextInputView.textContentType = nil; + } cn1TextInputView.keyboardType = kt; cn1TextInputView.secureTextEntry = secure; if (secure) { diff --git a/Ports/iOSPort/nativeSources/CodenameOne_GLViewController.m b/Ports/iOSPort/nativeSources/CodenameOne_GLViewController.m index 8fe92e550c6..10ed272f0bf 100644 --- a/Ports/iOSPort/nativeSources/CodenameOne_GLViewController.m +++ b/Ports/iOSPort/nativeSources/CodenameOne_GLViewController.m @@ -1108,6 +1108,21 @@ void cn1_setStyleDoneButton(CN1_THREAD_STATE_MULTI_ARG UIBarButtonItem* btn) { } } } + + // ONE_TIME_CODE, applied after the base type so it wins: it says what the + // value IS, where the others say how it is typed. iOS offers the code from + // the incoming message in the suggestion bar for a field that declares it, + // and offers nothing for a field that does not. + if((constraint & 0x1000000) == 0x1000000) { + if (@available(iOS 12, *)) { + utf.textContentType = UITextContentTypeOneTimeCode; + } + // and no correcting or capitalizing it: the keyboard would otherwise change + // a code between the user reading it and the server checking it. A digit code + // is safe by virtue of its keypad; one that takes letters is not. + utf.autocorrectionType = UITextAutocorrectionTypeNo; + utf.autocapitalizationType = UITextAutocapitalizationTypeNone; + } if(scale != 1) { float s = ((BRIDGE_CAST CN1Font*)font).pointSize / scale; utf.font = [((BRIDGE_CAST CN1Font*)font) fontWithSize:s]; @@ -1331,7 +1346,22 @@ void cn1_setStyleDoneButton(CN1_THREAD_STATE_MULTI_ARG UIBarButtonItem* btn) { } } } - + + // ONE_TIME_CODE, applied after the base type so it wins: it says what the + // value IS, where the others say how it is typed. iOS offers the code from + // the incoming message in the suggestion bar for a field that declares it, + // and offers nothing for a field that does not. + if((constraint & 0x1000000) == 0x1000000) { + if (@available(iOS 12, *)) { + utv.textContentType = UITextContentTypeOneTimeCode; + } + // and no correcting or capitalizing it: the keyboard would otherwise change + // a code between the user reading it and the server checking it. A digit code + // is safe by virtue of its keypad; one that takes letters is not. + utv.autocorrectionType = UITextAutocorrectionTypeNo; + utv.autocapitalizationType = UITextAutocapitalizationTypeNone; + } + #if !TARGET_OS_TV if(showToolbar) { //add navigation toolbar to the top of the keyboard diff --git a/Ports/iOSPort/nativeSources/iOSModernTheme.res b/Ports/iOSPort/nativeSources/iOSModernTheme.res index df6888d7767..a09e3b6e1db 100644 Binary files a/Ports/iOSPort/nativeSources/iOSModernTheme.res and b/Ports/iOSPort/nativeSources/iOSModernTheme.res differ diff --git a/Samples/samples/PhoneVerificationSample/PhoneVerificationSample.java b/Samples/samples/PhoneVerificationSample/PhoneVerificationSample.java new file mode 100644 index 00000000000..112fe6a6f4c --- /dev/null +++ b/Samples/samples/PhoneVerificationSample/PhoneVerificationSample.java @@ -0,0 +1,154 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. All rights reserved. + * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER. + * This code is free software; you can redistribute it and/or modify it + * under the terms of the GNU General Public License version 2 only, as + * published by the Free Software Foundation. Codename One designates this + * particular file as subject to the "Classpath" exception as provided + * by Oracle in the LICENSE file that accompanied this code. + * + * This code is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License + * version 2 for more details (a copy is included in the LICENSE file that + * accompanied this code). + * + * You should have received a copy of the GNU General Public License version + * 2 along with this work; if not, write to the Free Software Foundation, + * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA. + * + * Please contact Codename One through http://www.codenameone.com/ if you + * need additional information or have any questions. + */ +package com.codename1.samples; + +import com.codename1.components.OtpField; +import com.codename1.components.PhoneNumberField; +import com.codename1.components.PhoneVerification; +import com.codename1.components.SpanLabel; +import com.codename1.components.ToastBar; +import com.codename1.io.Log; +import com.codename1.ui.Button; +import com.codename1.ui.Display; +import com.codename1.ui.Form; +import com.codename1.ui.Label; +import com.codename1.ui.TextArea; +import com.codename1.ui.TextField; +import com.codename1.ui.Toolbar; +import com.codename1.ui.layouts.BoxLayout; +import com.codename1.ui.plaf.UIManager; +import com.codename1.ui.util.Resources; +import com.codename1.ui.util.UITimer; + +/// Phone number verification, end to end, against a fake server. +/// +/// There is nothing to configure: `sendCode` accepts any number and pretends to +/// send `123456` a second later, and `verifyCode` accepts that code and refuses +/// every other one. The point of the sample is the client half -- the segmented +/// code field, the resend countdown, the way back to a mistyped number, and the +/// one-time-code hint that lets the platform offer the code from a real message. +/// +/// On a device, put a real code screen beside this one: send yourself an SMS +/// containing a six digit code while the code stage is showing, and the platform +/// offers it above the keyboard (iOS) or through autofill (Android). Nothing in +/// this sample reads messages. +public class PhoneVerificationSample { + + private static final String FAKE_CODE = "123456"; + + private Form current; + private Resources theme; + + public void init(Object context) { + theme = UIManager.initFirstTheme("/theme"); + Toolbar.setGlobalToolbar(true); + Log.bindCrashProtection(true); + } + + public void start() { + if (current != null) { + current.show(); + return; + } + showFlow(); + } + + private void showFlow() { + Form f = new Form("Verify a phone number", BoxLayout.y()); + f.add(new SpanLabel("The server here is fake: any number is accepted, and the code is " + + FAKE_CODE + ".")); + + PhoneVerification verify = new PhoneVerification(); + verify.setResendDelay(10); + verify.setCodeSender(new PhoneVerification.CodeSender() { + public void sendCode(String e164Number, PhoneVerification.Response response) { + // a real sender posts to its own server; the delay is here so the + // disabled button and the countdown are visible + UITimer.timer(1000, false, new Runnable() { + public void run() { + response.succeeded(); + } + }); + } + }); + verify.setCodeVerifier(new PhoneVerification.CodeVerifier() { + public void verifyCode(String e164Number, String code, PhoneVerification.Response response) { + UITimer.timer(600, false, new Runnable() { + public void run() { + if (FAKE_CODE.equals(code)) { + response.succeeded(); + } else { + response.failed("That code is not " + FAKE_CODE); + } + } + }); + } + }); + verify.addVerifiedListener(e -> showVerified(verify.getPhoneNumber())); + f.add(verify); + + f.add(new Label(" ")); + f.add(new Label("The pieces on their own")); + f.add(bareOtpField()); + f.add(barePhoneField()); + f.show(); + } + + /// The code field used without the flow around it. + private com.codename1.ui.Container bareOtpField() { + OtpField otp = new OtpField(6); + Label read = new Label(""); + otp.addCompleteListener(e -> read.setText("complete: " + otp.getText())); + Button clear = new Button("Clear"); + clear.addActionListener(e -> otp.clear()); + return BoxLayout.encloseY(new Label("OtpField"), otp, read, clear); + } + + /// The number field used without the flow around it, and a plain TextField + /// carrying the same hint the OtpField carries. + private com.codename1.ui.Container barePhoneField() { + PhoneNumberField phone = new PhoneNumberField(); + Label read = new Label(""); + Button show = new Button("Read as E.164"); + show.addActionListener(e -> read.setText(String.valueOf(phone.getE164()))); + TextField plainCode = new TextField("", "Code in a plain field", 6, + TextArea.NUMERIC | TextArea.ONE_TIME_CODE); + return BoxLayout.encloseY(new Label("PhoneNumberField"), phone, show, read, + new Label("TextArea.ONE_TIME_CODE on a plain TextField"), plainCode); + } + + private void showVerified(String number) { + ToastBar.showMessage("Verified " + number, com.codename1.ui.FontImage.MATERIAL_CHECK); + } + + public void stop() { + current = Display.getInstance().getCurrent(); + if (current instanceof com.codename1.ui.Dialog) { + ((com.codename1.ui.Dialog) current).dispose(); + current = Display.getInstance().getCurrent(); + } + } + + public void destroy() { + } +} diff --git a/Samples/samples/PhoneVerificationSample/codenameone_settings.properties b/Samples/samples/PhoneVerificationSample/codenameone_settings.properties new file mode 100644 index 00000000000..1f0d4923ae5 --- /dev/null +++ b/Samples/samples/PhoneVerificationSample/codenameone_settings.properties @@ -0,0 +1,4 @@ +#Phone verification sample build hints +# Nothing is required. The one-time-code hint the code field carries is a text field +# constraint, not a capability: no permission, no entitlement, no native dependency and +# nothing to switch on. A platform that cannot offer the code lets the user type it. diff --git a/Themes/AndroidMaterialTheme.res b/Themes/AndroidMaterialTheme.res index dc9bf64f231..2b8897c3e92 100644 Binary files a/Themes/AndroidMaterialTheme.res and b/Themes/AndroidMaterialTheme.res differ diff --git a/Themes/iOSModernTheme.res b/Themes/iOSModernTheme.res index df6888d7767..a09e3b6e1db 100644 Binary files a/Themes/iOSModernTheme.res and b/Themes/iOSModernTheme.res differ diff --git a/docs/demos/common/src/main/java/com/codenameone/developerguide/snippets/generated/PhoneNumberVerificationJava001Snippet.java b/docs/demos/common/src/main/java/com/codenameone/developerguide/snippets/generated/PhoneNumberVerificationJava001Snippet.java new file mode 100644 index 00000000000..19a5eeae523 --- /dev/null +++ b/docs/demos/common/src/main/java/com/codenameone/developerguide/snippets/generated/PhoneNumberVerificationJava001Snippet.java @@ -0,0 +1,97 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. All rights reserved. + * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER. + * This code is free software; you can redistribute it and/or modify it + * under the terms of the GNU General Public License version 2 only, as + * published by the Free Software Foundation. Codename One designates this + * particular file as subject to the "Classpath" exception as provided + * by Oracle in the LICENSE file that accompanied this code. + * + * This code is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License + * version 2 for more details (a copy is included in the LICENSE file that + * accompanied this code). + * + * You should have received a copy of the GNU General Public License version + * 2 along with this work; if not, write to the Free Software Foundation, + * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA. + * + * Please contact Codename One through http://www.codenameone.com/ if you + * need additional information or have any questions. + */ +package com.codenameone.developerguide.snippets.generated; + +import com.codename1.gpu.*; +import com.codename1.ui.*; +import com.codename1.ui.animations.*; +import com.codename1.ui.events.*; +import com.codename1.ui.geom.*; +import com.codename1.ui.layouts.*; +import com.codename1.ui.list.*; +import com.codename1.ui.plaf.*; +import com.codename1.ui.util.*; +import com.codename1.components.*; +import com.codename1.charts.models.*; +import com.codename1.charts.renderers.*; +import com.codename1.charts.views.*; +import com.codename1.capture.*; +import com.codename1.io.*; +import com.codename1.l10n.*; +import com.codename1.location.*; +import com.codename1.maps.*; +import com.codename1.media.*; +import com.codename1.messaging.*; +import com.codename1.payment.*; +import com.codename1.processing.*; +import com.codename1.properties.*; +import com.codename1.push.*; +import com.codename1.security.*; +import com.codename1.social.*; +import com.codename1.ui.spinner.*; +import java.io.*; +import java.util.*; + +import com.codename1.io.rest.*; + +class PhoneNumberVerificationJava001Snippet { + + + Object context; + Object url; + Object value; + Object body; + Object event; + String apiKey = "test-key"; + String myHttpsURL = "https://example.com"; + java.util.List validKeysList = new java.util.ArrayList<>(); + Image myImage; + Graphics graphics; + Graphics g; + GraphicsDevice device; + Form form; + Form hi; + Container cnt; + Container myForm; + Component component; + Button button; + MultiButton myMultiButton; + Label label; + BrowserComponent browserComponent; + Resources theme; + void snippet() throws Exception { + // tag::phone-number-verification-java-001[] + PhoneVerification verify = new PhoneVerification(); + verify.setCodeSender((number, response) -> myServerSendsCode(number, response)); + verify.setCodeVerifier((number, code, response) -> myServerChecksCode(number, code, response)); + verify.addVerifiedListener(e -> onVerified(verify.getPhoneNumber())); + form.add(verify); + // end::phone-number-verification-java-001[] + } + + void myServerSendsCode(String number, PhoneVerification.Response response) { } + + void myServerChecksCode(String number, String code, PhoneVerification.Response response) { } + + void onVerified(String number) { } +} diff --git a/docs/demos/common/src/main/java/com/codenameone/developerguide/snippets/generated/PhoneNumberVerificationJava002Snippet.java b/docs/demos/common/src/main/java/com/codenameone/developerguide/snippets/generated/PhoneNumberVerificationJava002Snippet.java new file mode 100644 index 00000000000..3b8f9dbe54f --- /dev/null +++ b/docs/demos/common/src/main/java/com/codenameone/developerguide/snippets/generated/PhoneNumberVerificationJava002Snippet.java @@ -0,0 +1,87 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. All rights reserved. + * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER. + * This code is free software; you can redistribute it and/or modify it + * under the terms of the GNU General Public License version 2 only, as + * published by the Free Software Foundation. Codename One designates this + * particular file as subject to the "Classpath" exception as provided + * by Oracle in the LICENSE file that accompanied this code. + * + * This code is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License + * version 2 for more details (a copy is included in the LICENSE file that + * accompanied this code). + * + * You should have received a copy of the GNU General Public License version + * 2 along with this work; if not, write to the Free Software Foundation, + * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA. + * + * Please contact Codename One through http://www.codenameone.com/ if you + * need additional information or have any questions. + */ +package com.codenameone.developerguide.snippets.generated; + +import com.codename1.gpu.*; +import com.codename1.ui.*; +import com.codename1.ui.animations.*; +import com.codename1.ui.events.*; +import com.codename1.ui.geom.*; +import com.codename1.ui.layouts.*; +import com.codename1.ui.list.*; +import com.codename1.ui.plaf.*; +import com.codename1.ui.util.*; +import com.codename1.components.*; +import com.codename1.charts.models.*; +import com.codename1.charts.renderers.*; +import com.codename1.charts.views.*; +import com.codename1.capture.*; +import com.codename1.io.*; +import com.codename1.l10n.*; +import com.codename1.location.*; +import com.codename1.maps.*; +import com.codename1.media.*; +import com.codename1.messaging.*; +import com.codename1.payment.*; +import com.codename1.processing.*; +import com.codename1.properties.*; +import com.codename1.push.*; +import com.codename1.security.*; +import com.codename1.social.*; +import com.codename1.ui.spinner.*; +import java.io.*; +import java.util.*; + +import com.codename1.io.rest.*; + +class PhoneNumberVerificationJava002Snippet { + + + Object context; + Object url; + Object value; + Object body; + Object event; + String apiKey = "test-key"; + String myHttpsURL = "https://example.com"; + java.util.List validKeysList = new java.util.ArrayList<>(); + Image myImage; + Graphics graphics; + Graphics g; + GraphicsDevice device; + Form form; + Form hi; + Container cnt; + Container myForm; + Component component; + Button button; + MultiButton myMultiButton; + Label label; + BrowserComponent browserComponent; + Resources theme; + void snippet() throws Exception { + // tag::phone-number-verification-java-002[] + TextField code = new TextField("", "Code", 6, TextArea.NUMERIC | TextArea.ONE_TIME_CODE); + // end::phone-number-verification-java-002[] + } +} diff --git a/docs/demos/common/src/main/java/com/codenameone/developerguide/snippets/generated/PhoneNumberVerificationJava003Snippet.java b/docs/demos/common/src/main/java/com/codenameone/developerguide/snippets/generated/PhoneNumberVerificationJava003Snippet.java new file mode 100644 index 00000000000..eef9263690a --- /dev/null +++ b/docs/demos/common/src/main/java/com/codenameone/developerguide/snippets/generated/PhoneNumberVerificationJava003Snippet.java @@ -0,0 +1,92 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. All rights reserved. + * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER. + * This code is free software; you can redistribute it and/or modify it + * under the terms of the GNU General Public License version 2 only, as + * published by the Free Software Foundation. Codename One designates this + * particular file as subject to the "Classpath" exception as provided + * by Oracle in the LICENSE file that accompanied this code. + * + * This code is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License + * version 2 for more details (a copy is included in the LICENSE file that + * accompanied this code). + * + * You should have received a copy of the GNU General Public License version + * 2 along with this work; if not, write to the Free Software Foundation, + * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA. + * + * Please contact Codename One through http://www.codenameone.com/ if you + * need additional information or have any questions. + */ +package com.codenameone.developerguide.snippets.generated; + +import com.codename1.gpu.*; +import com.codename1.ui.*; +import com.codename1.ui.animations.*; +import com.codename1.ui.events.*; +import com.codename1.ui.geom.*; +import com.codename1.ui.layouts.*; +import com.codename1.ui.list.*; +import com.codename1.ui.plaf.*; +import com.codename1.ui.util.*; +import com.codename1.components.*; +import com.codename1.charts.models.*; +import com.codename1.charts.renderers.*; +import com.codename1.charts.views.*; +import com.codename1.capture.*; +import com.codename1.io.*; +import com.codename1.l10n.*; +import com.codename1.location.*; +import com.codename1.maps.*; +import com.codename1.media.*; +import com.codename1.messaging.*; +import com.codename1.payment.*; +import com.codename1.processing.*; +import com.codename1.properties.*; +import com.codename1.push.*; +import com.codename1.security.*; +import com.codename1.social.*; +import com.codename1.ui.spinner.*; +import java.io.*; +import java.util.*; + +import com.codename1.io.rest.*; + +class PhoneNumberVerificationJava003Snippet { + + + Object context; + Object url; + Object value; + Object body; + Object event; + String apiKey = "test-key"; + String myHttpsURL = "https://example.com"; + java.util.List validKeysList = new java.util.ArrayList<>(); + Image myImage; + Graphics graphics; + Graphics g; + GraphicsDevice device; + Form form; + Form hi; + Container cnt; + Container myForm; + Component component; + Button button; + MultiButton myMultiButton; + Label label; + BrowserComponent browserComponent; + Resources theme; + void snippet() throws Exception { + // tag::phone-number-verification-java-003[] + OtpField otp = new OtpField(6); + otp.addCompleteListener(e -> checkCode(otp.getText())); + form.add(otp); + otp.startEditing(); + // end::phone-number-verification-java-003[] + } + + void checkCode(String code) { } +} diff --git a/docs/demos/common/src/main/java/com/codenameone/developerguide/snippets/generated/PhoneNumberVerificationJava004Snippet.java b/docs/demos/common/src/main/java/com/codenameone/developerguide/snippets/generated/PhoneNumberVerificationJava004Snippet.java new file mode 100644 index 00000000000..848933cb8ce --- /dev/null +++ b/docs/demos/common/src/main/java/com/codenameone/developerguide/snippets/generated/PhoneNumberVerificationJava004Snippet.java @@ -0,0 +1,93 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. All rights reserved. + * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER. + * This code is free software; you can redistribute it and/or modify it + * under the terms of the GNU General Public License version 2 only, as + * published by the Free Software Foundation. Codename One designates this + * particular file as subject to the "Classpath" exception as provided + * by Oracle in the LICENSE file that accompanied this code. + * + * This code is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License + * version 2 for more details (a copy is included in the LICENSE file that + * accompanied this code). + * + * You should have received a copy of the GNU General Public License version + * 2 along with this work; if not, write to the Free Software Foundation, + * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA. + * + * Please contact Codename One through http://www.codenameone.com/ if you + * need additional information or have any questions. + */ +package com.codenameone.developerguide.snippets.generated; + +import com.codename1.gpu.*; +import com.codename1.ui.*; +import com.codename1.ui.animations.*; +import com.codename1.ui.events.*; +import com.codename1.ui.geom.*; +import com.codename1.ui.layouts.*; +import com.codename1.ui.list.*; +import com.codename1.ui.plaf.*; +import com.codename1.ui.util.*; +import com.codename1.components.*; +import com.codename1.charts.models.*; +import com.codename1.charts.renderers.*; +import com.codename1.charts.views.*; +import com.codename1.capture.*; +import com.codename1.io.*; +import com.codename1.l10n.*; +import com.codename1.location.*; +import com.codename1.maps.*; +import com.codename1.media.*; +import com.codename1.messaging.*; +import com.codename1.payment.*; +import com.codename1.processing.*; +import com.codename1.properties.*; +import com.codename1.push.*; +import com.codename1.security.*; +import com.codename1.social.*; +import com.codename1.ui.spinner.*; +import java.io.*; +import java.util.*; + +import com.codename1.io.rest.*; + +class PhoneNumberVerificationJava004Snippet { + + + Object context; + Object url; + Object value; + Object body; + Object event; + String apiKey = "test-key"; + String myHttpsURL = "https://example.com"; + java.util.List validKeysList = new java.util.ArrayList<>(); + Image myImage; + Graphics graphics; + Graphics g; + GraphicsDevice device; + Form form; + Form hi; + Container cnt; + Container myForm; + Component component; + Button button; + MultiButton myMultiButton; + Label label; + BrowserComponent browserComponent; + Resources theme; + void snippet() throws Exception { + // tag::phone-number-verification-java-004[] + PhoneNumberField phone = new PhoneNumberField(); + form.add(phone); + // the user picks Israel and types 50-123-4567; separators are dropped + String number = phone.getE164(); // "+972501234567" + boolean plausible = phone.isValid(); + // typing the trunk prefix they say out loud, 050-123-4567, keeps it: + // "+9720501234567". Your sending service normalizes that -- see below. + // end::phone-number-verification-java-004[] + } +} diff --git a/docs/demos/common/src/main/java/com/codenameone/developerguide/snippets/generated/PhoneNumberVerificationJava005Snippet.java b/docs/demos/common/src/main/java/com/codenameone/developerguide/snippets/generated/PhoneNumberVerificationJava005Snippet.java new file mode 100644 index 00000000000..be4c3e7371f --- /dev/null +++ b/docs/demos/common/src/main/java/com/codenameone/developerguide/snippets/generated/PhoneNumberVerificationJava005Snippet.java @@ -0,0 +1,97 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. All rights reserved. + * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER. + * This code is free software; you can redistribute it and/or modify it + * under the terms of the GNU General Public License version 2 only, as + * published by the Free Software Foundation. Codename One designates this + * particular file as subject to the "Classpath" exception as provided + * by Oracle in the LICENSE file that accompanied this code. + * + * This code is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License + * version 2 for more details (a copy is included in the LICENSE file that + * accompanied this code). + * + * You should have received a copy of the GNU General Public License version + * 2 along with this work; if not, write to the Free Software Foundation, + * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA. + * + * Please contact Codename One through http://www.codenameone.com/ if you + * need additional information or have any questions. + */ +package com.codenameone.developerguide.snippets.generated; + +import com.codename1.gpu.*; +import com.codename1.ui.*; +import com.codename1.ui.animations.*; +import com.codename1.ui.events.*; +import com.codename1.ui.geom.*; +import com.codename1.ui.layouts.*; +import com.codename1.ui.list.*; +import com.codename1.ui.plaf.*; +import com.codename1.ui.util.*; +import com.codename1.components.*; +import com.codename1.charts.models.*; +import com.codename1.charts.renderers.*; +import com.codename1.charts.views.*; +import com.codename1.capture.*; +import com.codename1.io.*; +import com.codename1.l10n.*; +import com.codename1.location.*; +import com.codename1.maps.*; +import com.codename1.media.*; +import com.codename1.messaging.*; +import com.codename1.payment.*; +import com.codename1.processing.*; +import com.codename1.properties.*; +import com.codename1.push.*; +import com.codename1.security.*; +import com.codename1.social.*; +import com.codename1.ui.spinner.*; +import java.io.*; +import java.util.*; + +import com.codename1.io.rest.*; + +class PhoneNumberVerificationJava005Snippet { + + + Object context; + Object url; + Object value; + Object body; + Object event; + String apiKey = "test-key"; + String myHttpsURL = "https://example.com"; + java.util.List validKeysList = new java.util.ArrayList<>(); + Image myImage; + Graphics graphics; + Graphics g; + GraphicsDevice device; + Form form; + Form hi; + Container cnt; + Container myForm; + Component component; + Button button; + MultiButton myMultiButton; + Label label; + BrowserComponent browserComponent; + Resources theme; + void snippet() throws Exception { + // tag::phone-number-verification-java-005[] + PhoneNumberField phone = new PhoneNumberField(); + phone.setCountries(new PhoneNumberField.Country[]{ + PhoneNumberField.findCountry("IL"), + PhoneNumberField.findCountry("US"), + PhoneNumberField.findCountry("GB") + }); + phone.setE164(lastNumberWeSawForThisUser()); + // end::phone-number-verification-java-005[] + } + + String lastNumberWeSawForThisUser() { + return "+972501234567"; + } +} diff --git a/docs/demos/common/src/main/java/com/codenameone/developerguide/snippets/generated/PhoneNumberVerificationJava006Snippet.java b/docs/demos/common/src/main/java/com/codenameone/developerguide/snippets/generated/PhoneNumberVerificationJava006Snippet.java new file mode 100644 index 00000000000..ee83827136f --- /dev/null +++ b/docs/demos/common/src/main/java/com/codenameone/developerguide/snippets/generated/PhoneNumberVerificationJava006Snippet.java @@ -0,0 +1,100 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. All rights reserved. + * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER. + * This code is free software; you can redistribute it and/or modify it + * under the terms of the GNU General Public License version 2 only, as + * published by the Free Software Foundation. Codename One designates this + * particular file as subject to the "Classpath" exception as provided + * by Oracle in the LICENSE file that accompanied this code. + * + * This code is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License + * version 2 for more details (a copy is included in the LICENSE file that + * accompanied this code). + * + * You should have received a copy of the GNU General Public License version + * 2 along with this work; if not, write to the Free Software Foundation, + * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA. + * + * Please contact Codename One through http://www.codenameone.com/ if you + * need additional information or have any questions. + */ +package com.codenameone.developerguide.snippets.generated; + +import com.codename1.gpu.*; +import com.codename1.ui.*; +import com.codename1.ui.animations.*; +import com.codename1.ui.events.*; +import com.codename1.ui.geom.*; +import com.codename1.ui.layouts.*; +import com.codename1.ui.list.*; +import com.codename1.ui.plaf.*; +import com.codename1.ui.util.*; +import com.codename1.components.*; +import com.codename1.charts.models.*; +import com.codename1.charts.renderers.*; +import com.codename1.charts.views.*; +import com.codename1.capture.*; +import com.codename1.io.*; +import com.codename1.l10n.*; +import com.codename1.location.*; +import com.codename1.maps.*; +import com.codename1.media.*; +import com.codename1.messaging.*; +import com.codename1.payment.*; +import com.codename1.processing.*; +import com.codename1.properties.*; +import com.codename1.push.*; +import com.codename1.security.*; +import com.codename1.social.*; +import com.codename1.ui.spinner.*; +import java.io.*; +import java.util.*; + +import com.codename1.io.rest.*; + +class PhoneNumberVerificationJava006Snippet { + + + Object context; + Object url; + Object value; + Object body; + Object event; + String apiKey = "test-key"; + String myHttpsURL = "https://example.com"; + java.util.List validKeysList = new java.util.ArrayList<>(); + Image myImage; + Graphics graphics; + Graphics g; + GraphicsDevice device; + Form form; + Form hi; + Container cnt; + Container myForm; + Component component; + Button button; + MultiButton myMultiButton; + Label label; + BrowserComponent browserComponent; + Resources theme; + void snippet() throws Exception { + // tag::phone-number-verification-java-006[] + PhoneVerification verify = new PhoneVerification(); + verify.setCodeVerifier((number, code, response) -> myServerChecksCode(number, code, response)); + + // we sent the message ourselves, so start at the code + verify.showCodeStage("+972501234567"); + + // ... and drive the rest from our own controls + myOwnVerifyButton.addActionListener(e -> verify.submitCode()); + myOwnEditNumberButton.addActionListener(e -> verify.showNumberStage()); + // end::phone-number-verification-java-006[] + } + + Button myOwnVerifyButton = new Button(); + Button myOwnEditNumberButton = new Button(); + + void myServerChecksCode(String number, String code, PhoneVerification.Response response) { } +} diff --git a/docs/demos/common/src/main/java/com/codenameone/developerguide/snippets/generated/PhoneNumberVerificationJava007Snippet.java b/docs/demos/common/src/main/java/com/codenameone/developerguide/snippets/generated/PhoneNumberVerificationJava007Snippet.java new file mode 100644 index 00000000000..6fb7c937f91 --- /dev/null +++ b/docs/demos/common/src/main/java/com/codenameone/developerguide/snippets/generated/PhoneNumberVerificationJava007Snippet.java @@ -0,0 +1,117 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. All rights reserved. + * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER. + * This code is free software; you can redistribute it and/or modify it + * under the terms of the GNU General Public License version 2 only, as + * published by the Free Software Foundation. Codename One designates this + * particular file as subject to the "Classpath" exception as provided + * by Oracle in the LICENSE file that accompanied this code. + * + * This code is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License + * version 2 for more details (a copy is included in the LICENSE file that + * accompanied this code). + * + * You should have received a copy of the GNU General Public License version + * 2 along with this work; if not, write to the Free Software Foundation, + * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA. + * + * Please contact Codename One through http://www.codenameone.com/ if you + * need additional information or have any questions. + */ +package com.codenameone.developerguide.snippets.generated; + +import com.codename1.gpu.*; +import com.codename1.ui.*; +import com.codename1.ui.animations.*; +import com.codename1.ui.events.*; +import com.codename1.ui.geom.*; +import com.codename1.ui.layouts.*; +import com.codename1.ui.list.*; +import com.codename1.ui.plaf.*; +import com.codename1.ui.util.*; +import com.codename1.components.*; +import com.codename1.charts.models.*; +import com.codename1.charts.renderers.*; +import com.codename1.charts.views.*; +import com.codename1.capture.*; +import com.codename1.io.*; +import com.codename1.l10n.*; +import com.codename1.location.*; +import com.codename1.maps.*; +import com.codename1.media.*; +import com.codename1.messaging.*; +import com.codename1.payment.*; +import com.codename1.processing.*; +import com.codename1.properties.*; +import com.codename1.push.*; +import com.codename1.security.*; +import com.codename1.social.*; +import com.codename1.ui.spinner.*; +import java.io.*; +import java.util.*; + +import com.codename1.io.rest.*; + +class PhoneNumberVerificationJava007Snippet { + + + Object context; + Object url; + Object value; + Object body; + Object event; + String apiKey = "test-key"; + String myHttpsURL = "https://example.com"; + java.util.List validKeysList = new java.util.ArrayList<>(); + Image myImage; + Graphics graphics; + Graphics g; + GraphicsDevice device; + Form form; + Form hi; + Container cnt; + Container myForm; + Component component; + Button button; + MultiButton myMultiButton; + Label label; + BrowserComponent browserComponent; + Resources theme; + void snippet() throws Exception { + // tag::phone-number-verification-java-007[] + PhoneVerification verify = new PhoneVerification(); + + verify.setCodeSender((number, response) -> + Rest.post(myApi + "/verify/start") + .jsonContent() + .body("{\"phone\":\"" + number + "\"}") + .fetchAsJsonMap(result -> { + if (result.getResponseCode() == 200) { + response.succeeded(); + } else { + response.failed(null); + } + })); + + verify.setCodeVerifier((number, code, response) -> + Rest.post(myApi + "/verify/check") + .jsonContent() + .body("{\"phone\":\"" + number + "\",\"code\":\"" + code + "\"}") + .fetchAsJsonMap(result -> { + if (result.getResponseCode() == 200) { + // the session the server issued lives in the response body + storeSession(result.getResponseData()); + response.succeeded(); + } else { + response.failed(null); + } + })); + // end::phone-number-verification-java-007[] + } + + String myApi = "https://example.com/api"; + + void storeSession(Object body) { } +} diff --git a/docs/developer-guide/Authentication-And-Identity.asciidoc b/docs/developer-guide/Authentication-And-Identity.asciidoc index 927c2c4a55c..a52e7a8a63d 100644 --- a/docs/developer-guide/Authentication-And-Identity.asciidoc +++ b/docs/developer-guide/Authentication-And-Identity.asciidoc @@ -261,6 +261,13 @@ Or, if the provider exposes a discovery document: `OidcTokens#toAccessToken()` returns a `com.codename1.io.AccessToken`, so callers that already deal in `AccessToken` (most subclasses of `com.codename1.social.Login`) can adopt `OidcClient` without changing their token type. +[[verifying-a-phone-number]] +=== Verifying a phone number + +Signing a user in by phone number is a flow of its own rather than an OpenID Connect one: your server sends a code to the number and checks the code the user types back, and the device's part is entering the number, entering the code, and letting the platform offer the code out of the arriving message so the user never types it. + +`com.codename1.components.PhoneVerification` is that screen, and `com.codename1.ui.TextArea#ONE_TIME_CODE` is the hint that makes the platform offer the code without any messaging permission. See <>. + === A universal sign-in demo A complete sample is included under `samples/UniversalSignInDemo` in the repository. It renders one button per provider (Apple, Google, Microsoft, Facebook, Auth0, Firebase, generic OIDC) and dumps the resulting tokens to a `TextArea` so you can inspect them. See its `README.md` for the credentials wiring. diff --git a/docs/developer-guide/Phone-Number-Verification.asciidoc b/docs/developer-guide/Phone-Number-Verification.asciidoc new file mode 100644 index 00000000000..eca089df58d --- /dev/null +++ b/docs/developer-guide/Phone-Number-Verification.asciidoc @@ -0,0 +1,168 @@ +== Phone Number Verification + +Signing a user in by phone number, or proving that a number they typed is theirs, is the same two steps everywhere: send a code to the number, then check the code they type back. This chapter covers the device half of that -- entering the number, entering the code, and letting the platform hand the code over so the user never types it. + +=== Who does what + +Codename One doesn't send the message. The service that does is yours, and which one you use (Twilio, Vonage, AWS SNS, Firebase Phone Auth, your own SMPP gateway) is a decision the framework has no part in. It also doesn't judge a code: the code exists on your server, and a client that could check it could also be persuaded to lie. + +[cols="1,1"] +|=== +| Your server | Codename One + +| Generates the code, sends the message, expires it, rate limits it +| Collects the number in E.164 form + +| Decides whether a submitted code is correct +| Collects the code, and accepts the one the platform offers + +| Issues whatever session or token follows +| Owns the screen: the two stages, the resend wait, the way back to a mistyped number, and the errors your server reports +|=== + +=== Quick start + +`com.codename1.components.PhoneVerification` is the whole screen. You supply the two server calls: + +[source,java] +---- +include::../demos/common/src/main/java/com/codenameone/developerguide/snippets/generated/PhoneNumberVerificationJava001Snippet.java[tag=phone-number-verification-java-001,indent=0] +---- + +That's a working verification screen. It starts on the number, moves to the code once your server accepts the number, submits the code as soon as the last box is filled, and reports the result. + +=== The code arrives by itself + +The field the code goes into carries `com.codename1.ui.TextArea#ONE_TIME_CODE`. The constraint has no behaviour of its own -- it's a statement about what the field holds, and it's what makes the platform offer the arriving code: + +[cols="1,2"] +|=== +| Platform | What the hint buys + +| iOS +| The keyboard's suggestion bar offers the code from Messages, above the number pad. + +| Android +| The autofill service offers the code from the SMS on the field. + +| Web +| The input is marked `one-time-code`, which is what a password manager matches on, and what puts the code in iOS Safari's suggestion bar. + +| Desktop and everywhere else +| Nothing changes. The user types the code. +|=== + +None of these routes reads messages, and none asks for a messaging permission. Your application says what the field is for; the platform decides what to offer, and the user decides whether to accept it. + +WARNING: A field without the hint is offered nothing, on every platform. The only alternative left is reading the SMS yourself, which on Android means holding `READ_SMS` -- a permission Google Play restricts to applications whose core function is messaging, and one that has sunk more than one app review. + +The constraint is worth setting on any field of your own that holds a code, even outside this flow: + +[source,java] +---- +include::../demos/common/src/main/java/com/codenameone/developerguide/snippets/generated/PhoneNumberVerificationJava002Snippet.java[tag=phone-number-verification-java-002,indent=0] +---- + +=== The code field + +`com.codename1.components.OtpField` draws the code one box per digit and tells you when it's complete: + +[source,java] +---- +include::../demos/common/src/main/java/com/codenameone/developerguide/snippets/generated/PhoneNumberVerificationJava003Snippet.java[tag=phone-number-verification-java-003,indent=0] +---- + +`getText()` returns what has been entered so far, `isComplete()` says whether every box is filled, `clear()` empties the field and puts the caret back at the start, and `startEditing()` opens the keyboard, which saves the user a tap on a screen that exists for one purpose. + +The boxes are drawn, and one field behind them holds the whole code. That's what lets an offered code land in a single step, and it's not an implementation detail you can ignore if you build your own: a code is one value, both mobile ports enforce a field's maximum length as a hard native filter, and a six-character code offered to a row of one-character fields is truncated to its first digit. `getBox(int)` returns the box that displays a character, for theming; the value is read and written through the field. + +=== Entering the number + +`com.codename1.components.PhoneNumberField` pairs a country selector with a number field and produces one E.164 string -- a leading `+`, the calling code, then the national number, digits only: + +[source,java] +---- +include::../demos/common/src/main/java/com/codenameone/developerguide/snippets/generated/PhoneNumberVerificationJava004Snippet.java[tag=phone-number-verification-java-004,indent=0] +---- + +It starts on the country the device reports and offers every calling code in a searchable list. An application that serves three countries has no reason to show two hundred, and one that already knows the number can set it: + +[source,java] +---- +include::../demos/common/src/main/java/com/codenameone/developerguide/snippets/generated/PhoneNumberVerificationJava005Snippet.java[tag=phone-number-verification-java-005,indent=0] +---- + +Country names are English, and each is looked up in the theme's resource bundle first under `Country.` plus the ISO 3166 code, so an application that ships translations gets them without replacing the list. + +Two things the field doesn't do, by design: + +* **It doesn't strip a national trunk prefix.** A leading `0` is a trunk prefix in Israel and part of the number in Italy, and telling them apart is a per-country rule this field doesn't carry. Users type their number the way they know it; your sending service normalizes. +* **It doesn't decide a number exists.** `isValid()` checks the shape -- a national part that's present and short enough to keep the whole number inside E.164's fifteen digits -- and that's all a client can check. The service that sends the message is the authority, and its refusal reaches the user like any other failure. + +Several countries share a calling code: `+1` covers the United States, Canada and much of the Caribbean, which the North American area code tells apart and this field doesn't. `setE164` keeps the country already selected when its code matches, and otherwise takes the first country listed for that code. + +=== The flow in detail + +Each server call is handed a `PhoneVerification.Response` and calls exactly one of `succeeded()` or `failed(String)` when the server answers. Either may be called from any thread, so a callback on a networking thread needs no hop of its own. + +Three behaviours are worth knowing, because they're the ones that get rewritten by hand on every verification screen: + +* **A request in flight disables the button that started it,** so a second tap can't send a second message. It's re-enabled when the response arrives. +* **A second answer to the same request is ignored** rather than rejected. A server wrapper that answers twice on a retry is a nuisance, not a reason to leave a screen stuck. +* **An answer to a request the user has moved past is dropped.** If they gave up waiting, went back, corrected the number and sent again, the first server's answer no longer describes the screen they're looking at, and applying it would move them somewhere they didn't ask to go. + +A failure message you pass to `failed(String)` is shown to the user as-is, so it should be something a user can act on; passing null shows a generic message instead. `addFailedListener` reports the same failures to your code, for counting attempts or logging them. + +Resend is held back for `setResendDelay(int)` seconds -- 60 by default -- with the remaining time shown on the button. Zero offers it at once. + +You don't have to use the built-in buttons. `requestCode(String)`, `submitCode()`, `showCodeStage(String)` and `showNumberStage()` are public, so a screen with its own layout can drive the same flow, and an application that sent the message itself can start at the second stage: + +[source,java] +---- +include::../demos/common/src/main/java/com/codenameone/developerguide/snippets/generated/PhoneNumberVerificationJava006Snippet.java[tag=phone-number-verification-java-006,indent=0] +---- + +=== Talking to your server + +The two callbacks are where your API lives. A typical pair over REST: + +[source,java] +---- +include::../demos/common/src/main/java/com/codenameone/developerguide/snippets/generated/PhoneNumberVerificationJava007Snippet.java[tag=phone-number-verification-java-007,indent=0] +---- + +A few things belong on the server side of that boundary rather than in the app: + +* Rate limit by number and by device. An unthrottled send endpoint is a way to bill you for somebody else's messages. +* Expire codes, and cap attempts per code. A six-digit code is a million guesses, which isn't many. +* Never return the code to the client, in any field, for any reason. +* Treat the number as unverified until your own check passes. The client says what the user typed, and a client can be modified. + +=== Trying it without a device + +In the simulator and on the desktop ports the hint changes nothing -- no message is arriving, so nothing is offered and the code is typed. Everything else works there: the stages, the countdown, the errors, and the field itself. + +`Samples/samples/PhoneVerificationSample` runs the whole flow against a fake server that accepts any number and one code, so the screen can be driven without a backend. On a device it's also how to exercise the platform's offer: send yourself a message with a code in it while the second stage is showing. + +=== Styling + +|=== +| UIID | Applies to + +| `OtpField` +| The code field as a whole. + +| `OtpDigit` +| One box of the code. + +| `PhoneNumberField`, `PhoneNumberCountry`, `PhoneNumberText` +| The number entry, its country selector and its number field. + +| `PhoneVerification`, `PhoneVerificationText`, `PhoneVerificationError` +| The flow, its explanatory line and its error line. + +| `PhoneVerificationButton`, `PhoneVerificationLink` +| The send and verify buttons, and the resend and change-number buttons. +|=== + +The shipped native themes style these. A theme of your own that doesn't define `OtpDigit` gets the theme's default component style for the boxes, which is seldom what you want -- derive it from `TextField`. diff --git a/docs/developer-guide/developer-guide.asciidoc b/docs/developer-guide/developer-guide.asciidoc index 8189830ce32..f4097901b87 100644 --- a/docs/developer-guide/developer-guide.asciidoc +++ b/docs/developer-guide/developer-guide.asciidoc @@ -141,6 +141,8 @@ include::Biometric-Authentication.asciidoc[] include::Authentication-And-Identity.asciidoc[] +include::Phone-Number-Verification.asciidoc[] + include::Deep-Links-Routing.asciidoc[] include::App-Intents.asciidoc[] diff --git a/docs/developer-guide/languagetool-accept.txt b/docs/developer-guide/languagetool-accept.txt index 9b46980eb4b..3431b61c540 100644 --- a/docs/developer-guide/languagetool-accept.txt +++ b/docs/developer-guide/languagetool-accept.txt @@ -715,3 +715,10 @@ IPsec # Android's density-independent pixel, the unit its safe-area insets are # published in. Spelled the way Google spells it, alongside the iOS "pt". dp + +# ----------------------------------------------------------------------------- +# Phone verification (Authentication-And-Identity.asciidoc). +# ----------------------------------------------------------------------------- +# One of the SMS services an application's own server might send the code +# through, named alongside Twilio and AWS SNS. +Vonage diff --git a/maven/core-unittests/src/test/java/com/codename1/components/OtpFieldTest.java b/maven/core-unittests/src/test/java/com/codename1/components/OtpFieldTest.java index adac504b15d..4e9157c5407 100644 --- a/maven/core-unittests/src/test/java/com/codename1/components/OtpFieldTest.java +++ b/maven/core-unittests/src/test/java/com/codename1/components/OtpFieldTest.java @@ -1,5 +1,5 @@ /* - * Copyright (c) 2008-2026, Codename One and/or its affiliates. All rights reserved. + * Copyright (c) 2026, Codename One and/or its affiliates. All rights reserved. * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER. * This code is free software; you can redistribute it and/or modify it * under the terms of the GNU General Public License version 2 only, as @@ -24,7 +24,16 @@ import com.codename1.junit.FormTest; import com.codename1.junit.UITestBase; +import com.codename1.ui.EditField; +import com.codename1.ui.Container; +import com.codename1.ui.Form; import com.codename1.ui.TextField; +import com.codename1.ui.layouts.BorderLayout; +import com.codename1.ui.layouts.BoxLayout; +import com.codename1.ui.TextArea; +import com.codename1.ui.TextInputConfig; +import com.codename1.ui.plaf.LookAndFeel; +import com.codename1.ui.plaf.UIManager; import com.codename1.ui.events.ActionEvent; import com.codename1.ui.events.ActionListener; @@ -33,10 +42,10 @@ import static org.junit.jupiter.api.Assertions.*; /** - * Exercises {@link OtpField} through its public API: construction guards, - * value get/set, box structure, the auto-advance / backspace editing logic, - * paste distribution and the completion-listener firing. All driven on the - * EDT (via {@link FormTest}) since the boxes call {@code startEditingAsync}. + * Exercises {@link OtpField} through its public API: construction guards, the + * value, the boxes that display it, and the single field that owns it -- + * including the path a platform-offered code takes, which is the reason the + * whole code lands in one field rather than one character per box. */ class OtpFieldTest extends UITestBase { @@ -46,7 +55,7 @@ class OtpFieldTest extends UITestBase { void defaultConstructorIsSixNumericBoxes() { OtpField f = new OtpField(); assertEquals(6, f.getLength()); - assertEquals(6, f.getComponentCount()); + assertTrue(f.isNumericOnly()); assertEquals("OtpField", f.getUIID()); assertEquals("OtpDigit", f.getBox(0).getUIID()); } @@ -55,22 +64,11 @@ void defaultConstructorIsSixNumericBoxes() { void lengthConstructorHonoursLength() { OtpField f = new OtpField(4); assertEquals(4, f.getLength()); - assertEquals(4, f.getComponentCount()); for (int i = 0; i < 4; i++) { assertNotNull(f.getBox(i)); - assertSame(f.getBox(i), f.getComponentAt(i)); } } - @FormTest - void numericConstructorAppliesNumericConstraint() { - OtpField numeric = new OtpField(4, true); - assertEquals(TextField.NUMERIC, numeric.getBox(0).getConstraint()); - - OtpField anyChar = new OtpField(4, false); - assertEquals(0, anyChar.getBox(0).getConstraint()); - } - @FormTest void constructorRejectsTooShortLength() { assertThrows(IllegalArgumentException.class, () -> new OtpField(1)); @@ -87,6 +85,32 @@ void boundaryLengthsAreAccepted() { assertEquals(16, new OtpField(16).getLength()); } + // ---- the hint that makes the platform offer the code ------------- + + @FormTest + void inputCarriesTheOneTimeCodeHint() { + EditField input = new OtpField(6).getInputField(); + assertNotEquals(0, input.getConstraint() & TextArea.ONE_TIME_CODE, + "without the hint no platform offers the code from the SMS"); + assertNotEquals(0, input.getConstraint() & TextArea.NUMERIC); + } + + @FormTest + void nonNumericFieldStillCarriesTheHint() { + EditField input = new OtpField(6, false).getInputField(); + assertNotEquals(0, input.getConstraint() & TextArea.ONE_TIME_CODE); + assertEquals(0, input.getConstraint() & TextArea.NUMERIC); + } + + @FormTest + void theKeyboardIsNotAllowedToCorrectOrCapitaliseTheCode() { + // the platform applies both before the value ever reaches the field, so a + // corrected code is a code the user typed correctly and the server rejects + TextInputConfig cfg = new OtpField(6, false).getInputField().getConfig(); + assertFalse(cfg.isAutoCorrect()); + assertFalse(cfg.isAutoCapitalize()); + } + // ---- value get / set -------------------------------------------- @FormTest @@ -115,7 +139,7 @@ void setTextShorterLeavesTrailingBoxesEmpty() { } @FormTest - void setTextNullClearsAllBoxes() { + void setTextNullClears() { OtpField f = new OtpField(4); f.setText("1234"); f.setText(null); @@ -123,12 +147,10 @@ void setTextNullClearsAllBoxes() { } @FormTest - void getTextOmitsEmptyBoxesForPartialEntry() { - OtpField f = new OtpField(6); - f.getBox(0).setText("9"); - f.getBox(2).setText("7"); - // boxes 1, 3, 4, 5 left empty -> concatenation skips them - assertEquals("97", f.getText()); + void setTextDropsCharactersTheFieldDoesNotAccept() { + OtpField f = new OtpField(4); + f.setText("1a2b3c4d"); + assertEquals("1234", f.getText()); } @FormTest @@ -137,113 +159,416 @@ void clearEmptiesEveryBox() { f.setText("424242"); f.clear(); assertEquals("", f.getText()); + assertEquals("", f.getBox(0).getText()); + assertFalse(f.isComplete()); } - // ---- editing behaviour (data-changed driven) -------------------- + @FormTest + void setTextLeavesTheCaretAfterTheLastCharacter() { + OtpField f = new OtpField(6); + f.setText("12"); + assertEquals(2, f.getInputField().getCaretOffset(), + "typing continues after what was set, not in front of it"); + } + + // ---- typing, pasting, and a code the platform offers ------------- @FormTest - void typingSingleCharAdvancesAndFinalKeyCompletes() { + void typingOneDigitAtATimeFillsTheBoxesInOrder() { OtpField f = new OtpField(3); + EditField input = f.getInputField(); + input.insertText("1"); + assertEquals("1", f.getBox(0).getText()); + assertEquals("", f.getBox(1).getText()); + input.insertText("2"); + input.insertText("3"); + assertEquals("123", f.getText()); + assertEquals("3", f.getBox(2).getText()); + } + + @FormTest + void wholeCodeArrivingAtOnceFillsEveryBox() { + // this is the platform offering the code out of the SMS, and it is also + // a paste: both arrive as one commit of the whole value + OtpField f = new OtpField(6); AtomicInteger fired = new AtomicInteger(); f.addCompleteListener(new ActionListener() { public void actionPerformed(ActionEvent evt) { fired.incrementAndGet(); } }); - // Type one digit at a time; each setText triggers the DataChangedListener. - f.getBox(0).setText("1"); - f.getBox(1).setText("2"); - assertEquals(0, fired.get(), "must not fire until the last box is filled"); - f.getBox(2).setText("3"); - assertEquals("123", f.getText()); - assertEquals(1, fired.get(), "completion fires exactly once when the field is full"); + f.getInputField().insertText("135790"); + assertEquals("135790", f.getText()); + assertEquals("1", f.getBox(0).getText()); + assertEquals("0", f.getBox(5).getText()); + assertEquals(1, fired.get()); } @FormTest - void backspaceOnEmptyBoxDoesNotFireOrThrow() { - OtpField f = new OtpField(3); - AtomicInteger fired = new AtomicInteger(); - f.addCompleteListener(evt -> fired.incrementAndGet()); - f.getBox(2).setText("3"); - // emptying a box (backspace) steps back; must not complete - f.getBox(2).setText(""); - assertEquals(0, fired.get()); - assertEquals("", f.getText()); + void charactersPastTheLastBoxAreDropped() { + OtpField f = new OtpField(4); + f.getInputField().insertText("123456789"); + assertEquals("1234", f.getText()); + } + + @FormTest + void typingSkipsNonDigitsWhenNumeric() { + OtpField f = new OtpField(4, true); + f.getInputField().insertText("1a2b3c4d"); + assertEquals("1234", f.getText()); } - // ---- paste distribution ----------------------------------------- + @FormTest + void typingKeepsNonDigitsWhenNotNumeric() { + OtpField f = new OtpField(4, false); + f.getInputField().insertText("ab12"); + assertEquals("ab12", f.getText()); + } @FormTest - void pasteIntoFirstBoxSpreadsAcrossBoxesAndCompletes() { + void lineBreaksNeverEnterTheCode() { + // a code pasted out of a message often carries the rest of the line + OtpField f = new OtpField(4, false); + f.getInputField().insertText("12\n34"); + assertEquals("1234", f.getText()); + } + + // ---- what the platform hands over ----------------------------------- + + @FormTest + void aCodeCommittedByThePlatformIsFilteredLikeATypedOne() { + // The Android autofill path commits the whole value into the field. An + // autofill service that keeps the message's separators hands over + // something the user could never have typed, and a field left holding it + // never reaches the length that completes it. OtpField f = new OtpField(6); AtomicInteger fired = new AtomicInteger(); - f.addCompleteListener(evt -> fired.incrementAndGet()); - // simulate a paste of the whole code into the first box - f.getBox(0).setText("135790"); - assertEquals("135790", f.getText()); - assertEquals("1", f.getBox(0).getText()); - assertEquals("0", f.getBox(5).getText()); + f.addCompleteListener(new ActionListener() { + public void actionPerformed(ActionEvent evt) { + fired.incrementAndGet(); + } + }); + f.getInputField().commitText("123-456"); + assertEquals("123456", f.getText()); + assertTrue(f.isComplete()); assertEquals(1, fired.get()); } @FormTest - void pasteSkipsNonDigitsWhenNumeric() { - OtpField f = new OtpField(4, true); - f.getBox(0).setText("1a2b3c4d"); - // non-digit chars are skipped, leaving the four digits + void aCommittedCodeReplacesWhateverWasThereRatherThanAppending() { + // the platform is answering "the value is this", so a half typed code is + // replaced rather than prefixed onto the offer + OtpField f = new OtpField(6); + f.setText("99"); + EditField input = f.getInputField(); + input.finishComposing(); + input.setSelectionRange(0, input.getText().length()); + input.commitText("123456"); + assertEquals("123456", f.getText()); + } + + @FormTest + void composedTextIsFilteredWhileItIsStillBeingComposed() { + // Dictation, handwriting and an IME all build text as a composition before + // committing it, and a composition writes to the document directly rather + // than through the typed-text path. Unfiltered, a numeric code field would + // hold letters for as long as the composition lasted. + OtpField f = new OtpField(6); + f.getInputField().setComposingText("12a3", 0); + assertEquals("123", f.getText()); + } + + @FormTest + void aCommitThatFinalizesACompositionIsFilteredToo() { + // the commit that ends a composition replaces the composed range directly, + // which is the one commit that never reaches the typed-text hook + OtpField f = new OtpField(6); + EditField input = f.getInputField(); + input.setComposingText("12", 0); + input.commitText("12b345"); + assertEquals("12345", f.getText()); + } + + @FormTest + void composedTextCannotOverfillTheField() { + OtpField f = new OtpField(4); + f.getInputField().setComposingText("123456789", 0); assertEquals("1234", f.getText()); } + // ---- tapping a box -------------------------------------------------- + @FormTest - void pasteKeepsNonDigitsWhenNotNumeric() { - OtpField f = new OtpField(4, false); - f.getBox(0).setText("ab12"); - assertEquals("ab12", f.getText()); + void tappingABoxPutsTheCaretInThatBox() { + // The inherited hit test measures the field's own text layout, which sits at + // the field's left edge and is never painted. A tap has to answer with the box + // the user aimed at, or a correction lands on the wrong digit. + OtpField f = new OtpField(6); + Form form = new Form("t", BoxLayout.y()); + form.add(f); + form.show(); + form.revalidate(); + f.setText("123456"); + form.revalidate(); + + EditField input = f.getInputField(); + for (int i = 0; i < 6; i++) { + TextField box = f.getBox(i); + int x = box.getAbsoluteX() + box.getWidth() / 2; + int y = box.getAbsoluteY() + box.getHeight() / 2; + assertEquals(i, input.offsetAtPoint(x, y), "tap on box " + i); + } + TextField last = f.getBox(5); + assertEquals(6, input.offsetAtPoint(last.getAbsoluteX() + last.getWidth() + 40, + last.getAbsoluteY() + 1), "a tap past the last box means the end"); } @FormTest - void pasteStartingMidFieldOnlyFillsFromThatIndex() { + void tappingAnEmptyBoxMeansTheEndOfWhatWasEntered() { + // the caller assigns this offset to the caret without clamping it, so an + // offset past the text would put the caret outside the document OtpField f = new OtpField(6); - f.getBox(2).setText("789"); - assertEquals("", f.getBox(0).getText()); - assertEquals("", f.getBox(1).getText()); - assertEquals("7", f.getBox(2).getText()); - assertEquals("9", f.getBox(4).getText()); - assertEquals("789", f.getText()); + Form form = new Form("t", BoxLayout.y()); + form.add(f); + form.show(); + form.revalidate(); + f.setText("12"); + form.revalidate(); + + TextField box = f.getBox(5); + assertEquals(2, f.getInputField().offsetAtPoint(box.getAbsoluteX() + 1, + box.getAbsoluteY() + 1)); } - // ---- listener management ---------------------------------------- + @FormTest + void theBoxesReadLeftToRightOnARightToLeftForm() { + // A code is digits, and digits read left to right everywhere; BoxLayout + // reverses its children on an RTL form, which would draw the first digit + // on the right and show the whole code backwards. The hit test walks the + // boxes in order, so it depends on this too. + LookAndFeel laf = UIManager.getInstance().getLookAndFeel(); + boolean wasRtl = laf.isRTL(); + laf.setRTL(true); + try { + OtpField f = new OtpField(6); + Form form = new Form("t", BoxLayout.y()); + form.add(f); + form.show(); + form.revalidate(); + f.setText("123456"); + form.revalidate(); + + for (int i = 1; i < 6; i++) { + assertTrue(f.getBox(i - 1).getAbsoluteX() < f.getBox(i).getAbsoluteX(), + "box " + (i - 1) + " must sit left of box " + i); + } + TextField third = f.getBox(2); + assertEquals(2, f.getInputField().offsetAtPoint( + third.getAbsoluteX() + third.getWidth() / 2, + third.getAbsoluteY() + third.getHeight() / 2), + "a tap must still land on the box it hit"); + } finally { + laf.setRTL(wasRtl); + } + } @FormTest - void removedListenerIsNotInvoked() { - OtpField f = new OtpField(2); + void everyBoxSurvivesALongCodeOnANarrowScreen() { + // A box row gives what is left of the width to one child and zero to every + // child after it, so the last boxes of a long code vanish on a narrow screen + // while the field still expects those characters. + OtpField f = new OtpField(16); + Form form = new Form("t", BoxLayout.y()); + form.add(f); + form.show(); + form.revalidate(); + + // The row holding the boxes is what runs out of width, so squeeze that + // directly: laying out an ancestor would not re-lay its grandchildren, and a + // test that only resizes an ancestor passes whatever the row does. + Container row = f.getBox(0).getParent(); + assertTrue(row.getPreferredW() > 240, "the row has to be squeezed to mean anything"); + row.setWidth(240); + // the layout manager directly: layoutContainer() is a no-op on a container that + // is not dirty, so a test that called it would lay nothing out and pass whatever + // the row does + row.getLayout().layoutContainer(row); + + for (int i = 0; i < 16; i++) { + assertTrue(f.getBox(i).getWidth() > 0, + "box " + i + " was squeezed out of existence"); + } + } + + @FormTest + void aFilledValueReplacesTheWholeFieldEvenMidComposition() { + // The sequence the Android autofill bridge drives. A commit replaces the composed + // range in preference to the selection, so without ending the composition first + // the filled code lands inside it and keeps what surrounded it -- here that would + // leave "991234", a full length wrong code that submits itself. + OtpField f = new OtpField(6); + EditField input = f.getInputField(); + input.commitText("99"); + input.setComposingText("12", 0); + assertEquals("9912", f.getText()); + + input.finishComposing(); + input.setSelectionRange(0, input.getText().length()); + input.commitText("123456"); + + assertEquals("123456", f.getText()); + } + + @FormTest + void aRangeReplacementIsFilteredLikeEverythingElse() { + // iOS delivers edits through UITextInput as range replacements, which reach + // neither the typed-text hook nor the commit + OtpField f = new OtpField(6); + f.setText("12"); + f.getInputField().replaceRange(2, 2, "a3"); + assertEquals("123", f.getText()); + } + + @FormTest + void aRangeReplacementCannotOverfillTheField() { + OtpField f = new OtpField(6); + f.getInputField().replaceRange(0, 0, "123456789"); + assertEquals("123456", f.getText()); + assertTrue(f.isComplete()); + } + + // ---- completion --------------------------------------------------- + + @FormTest + void completionFiresOnceWhenTheLastBoxFills() { + OtpField f = new OtpField(3); AtomicInteger fired = new AtomicInteger(); - ActionListener l = evt -> fired.incrementAndGet(); - f.addCompleteListener(l); - f.removeCompleteListener(l); - f.getBox(0).setText("1"); - f.getBox(1).setText("2"); - assertEquals(0, fired.get()); + f.addCompleteListener(new ActionListener() { + public void actionPerformed(ActionEvent evt) { + fired.incrementAndGet(); + } + }); + EditField input = f.getInputField(); + input.insertText("1"); + input.insertText("2"); + assertEquals(0, fired.get(), "must not fire until the last box is filled"); + input.insertText("3"); + assertEquals(1, fired.get()); + assertTrue(f.isComplete()); } @FormTest - void nullListenerIsIgnored() { - OtpField f = new OtpField(2); - f.addCompleteListener(null); - // no NPE when the field fills + void completionFiresAgainAfterTheCodeIsCorrected() { + OtpField f = new OtpField(3); + AtomicInteger fired = new AtomicInteger(); + f.addCompleteListener(new ActionListener() { + public void actionPerformed(ActionEvent evt) { + fired.incrementAndGet(); + } + }); + f.setText("123"); + assertEquals(1, fired.get()); f.setText("12"); - f.getBox(1).setText("3"); // re-trigger a change on the last box - assertEquals("13", f.getText()); + assertFalse(f.isComplete()); + assertEquals(1, fired.get()); + f.setText("124"); + assertEquals(2, fired.get(), "a corrected code is a new attempt"); + } + + @FormTest + void aCompositionDoesNotCompleteUntilItIsFinal() { + // An input method builds text before committing it. Firing on the provisional + // value submits a code it is still editing, and the flow that acts on it is + // busy by the time the corrected one arrives. + OtpField f = new OtpField(6); + AtomicInteger fired = new AtomicInteger(); + f.addCompleteListener(new ActionListener() { + public void actionPerformed(ActionEvent evt) { + fired.incrementAndGet(); + } + }); + EditField input = f.getInputField(); + + input.setComposingText("123456", 0); + assertEquals("123456", f.getText(), "the boxes still show what is being composed"); + assertEquals(0, fired.get(), "but a provisional value is not an answer"); + + input.setComposingText("123457", 0); + assertEquals(0, fired.get()); + + input.commitText("123457"); + assertEquals("123457", f.getText()); + assertEquals(1, fired.get(), "the committed value completes it, once"); + } + + @FormTest + void aCompositionFinishedWithoutACommitStillCompletes() { + // finishComposing changes no text, so nothing else would tell the field to look + OtpField f = new OtpField(6); + AtomicInteger fired = new AtomicInteger(); + f.addCompleteListener(new ActionListener() { + public void actionPerformed(ActionEvent evt) { + fired.incrementAndGet(); + } + }); + EditField input = f.getInputField(); + input.setComposingText("123456", 0); + assertEquals(0, fired.get()); + input.finishComposing(); + assertEquals(1, fired.get()); + } + + @FormTest + void replacingOneFullCodeWithAnotherIsANewAttempt() { + // The offered code accepted over a wrong one that was typed: full to full in a + // single edit, with no partial value in between. A flow that submits from this + // listener has to hear about the second one. + OtpField f = new OtpField(6); + AtomicInteger fired = new AtomicInteger(); + f.addCompleteListener(new ActionListener() { + public void actionPerformed(ActionEvent evt) { + fired.incrementAndGet(); + } + }); + f.setText("123456"); + assertEquals(1, fired.get()); + f.setText("654321"); + assertEquals(2, fired.get(), "a different full code is a new attempt"); + f.setText("654321"); + assertEquals(2, fired.get(), "the same one is not"); } @FormTest - void consumingListenerStopsLaterListeners() { - OtpField f = new OtpField(2); - AtomicInteger second = new AtomicInteger(); - f.addCompleteListener(ActionEvent::consume); - f.addCompleteListener(evt -> second.incrementAndGet()); - f.getBox(0).setText("1"); - f.getBox(1).setText("2"); - assertEquals(0, second.get(), "second listener skipped once the event is consumed"); + void aRangeReplacementEndingACompositionDoesNotSuppressCompletionForever() { + // iOS sends a range replacement that ends its marked text with no finishComposing + // behind it, so a provisional flag left standing would silence the field + OtpField f = new OtpField(6); + AtomicInteger fired = new AtomicInteger(); + f.addCompleteListener(new ActionListener() { + public void actionPerformed(ActionEvent evt) { + fired.incrementAndGet(); + } + }); + EditField input = f.getInputField(); + input.setComposingText("12", 0); + assertEquals(0, fired.get()); + input.replaceRange(2, 2, "3456"); + assertEquals("123456", f.getText()); + assertEquals(1, fired.get(), "the field has to speak again after a range edit"); + } + + @FormTest + void removedListenerStopsFiring() { + OtpField f = new OtpField(3); + AtomicInteger fired = new AtomicInteger(); + ActionListener l = new ActionListener() { + public void actionPerformed(ActionEvent evt) { + fired.incrementAndGet(); + } + }; + f.addCompleteListener(l); + f.removeCompleteListener(l); + f.setText("123"); + assertEquals(0, fired.get()); } } diff --git a/maven/core-unittests/src/test/java/com/codename1/components/PhoneNumberFieldTest.java b/maven/core-unittests/src/test/java/com/codename1/components/PhoneNumberFieldTest.java new file mode 100644 index 00000000000..0027c8ea246 --- /dev/null +++ b/maven/core-unittests/src/test/java/com/codename1/components/PhoneNumberFieldTest.java @@ -0,0 +1,424 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. All rights reserved. + * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER. + * This code is free software; you can redistribute it and/or modify it + * under the terms of the GNU General Public License version 2 only, as + * published by the Free Software Foundation. Codename One designates this + * particular file as subject to the "Classpath" exception as provided + * by Oracle in the LICENSE file that accompanied this code. + * + * This code is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License + * version 2 for more details (a copy is included in the LICENSE file that + * accompanied this code). + * + * You should have received a copy of the GNU General Public License version + * 2 along with this work; if not, write to the Free Software Foundation, + * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA. + * + * Please contact Codename One through http://www.codenameone.com/ if you + * need additional information or have any questions. + */ +package com.codename1.components; + +import com.codename1.components.PhoneNumberField.Country; +import com.codename1.junit.FormTest; +import com.codename1.junit.UITestBase; +import com.codename1.ui.TextArea; + +import java.util.HashSet; +import java.util.Set; + +import static org.junit.jupiter.api.Assertions.*; + +/** + * Exercises {@link PhoneNumberField}: the country table, the E.164 value it + * builds, and the parse that splits a number back into a country and the rest. + */ +class PhoneNumberFieldTest extends UITestBase { + + // ---- the country table ------------------------------------------ + + @FormTest + void tableCarriesEveryCountryWithACallingCode() { + Country[] all = PhoneNumberField.getAllCountries(); + assertTrue(all.length > 200, "expected the full E.164 region list, got " + all.length); + } + + @FormTest + void wellKnownCallingCodesAreCorrect() { + assertEquals("1", PhoneNumberField.findCountry("US").getDialCode()); + assertEquals("1", PhoneNumberField.findCountry("CA").getDialCode()); + assertEquals("44", PhoneNumberField.findCountry("GB").getDialCode()); + assertEquals("972", PhoneNumberField.findCountry("IL").getDialCode()); + assertEquals("91", PhoneNumberField.findCountry("IN").getDialCode()); + // the Caribbean NANP members share +1; 242 is the Bahamas' AREA code, + // which is part of the national number rather than the calling code + assertEquals("1", PhoneNumberField.findCountry("BS").getDialCode()); + } + + @FormTest + void countryLookupIsCaseInsensitiveAndMissesCleanly() { + assertEquals("IL", PhoneNumberField.findCountry("il").getIsoCode()); + assertNull(PhoneNumberField.findCountry("ZZ")); + assertNull(PhoneNumberField.findCountry(null)); + } + + @FormTest + void countryLookupSurvivesALocaleThatFoldsItsOwnWay() { + // String.toUpperCase folds with the device's locale: in Turkish "il" becomes a + // dotted capital I, which matches no ISO 3166 code. A device set to Turkish would + // have found no country at all. + java.util.Locale previous = java.util.Locale.getDefault(); + java.util.Locale.setDefault(new java.util.Locale("tr", "TR")); + try { + assertNotNull(PhoneNumberField.findCountry("il"), "lower case ISO code"); + assertEquals("IL", PhoneNumberField.findCountry("il").getIsoCode()); + assertEquals("IN", PhoneNumberField.findCountry("in").getIsoCode()); + assertEquals("IL", PhoneNumberField.findCountry("IL").getIsoCode()); + } finally { + java.util.Locale.setDefault(previous); + } + } + + @FormTest + void searchingFindsACountryByItsFirstLetterInAnyLocale() { + // "Israel".toLowerCase() is "israel" in most locales and "ısrael" in Turkish, + // while the i the user types stays dotted -- so the country could not be found + // by typing its first letter on a Turkish device + java.util.Locale previous = java.util.Locale.getDefault(); + java.util.Locale.setDefault(new java.util.Locale("tr", "TR")); + try { + Country il = PhoneNumberField.findCountry("IL"); + assertTrue(PhoneNumberField.matchesSearch("Israel", il, + PhoneNumberField.foldCase("i")), "by name"); + assertTrue(PhoneNumberField.matchesSearch("Israel", il, + PhoneNumberField.foldCase("IL")), "by ISO code"); + assertTrue(PhoneNumberField.matchesSearch("Israel", il, + PhoneNumberField.foldCase("972")), "by calling code"); + assertFalse(PhoneNumberField.matchesSearch("Israel", il, + PhoneNumberField.foldCase("zz")), "and still filters"); + assertTrue(PhoneNumberField.matchesSearch("Israel", il, + PhoneNumberField.foldCase("")), "an empty search offers everything"); + } finally { + java.util.Locale.setDefault(previous); + } + } + + @FormTest + void searchingStillMatchesAnAccentedNameByItsAccentedLetter() { + Country ax = PhoneNumberField.findCountry("AX"); + assertNotNull(ax); + assertTrue(PhoneNumberField.matchesSearch(ax.getName(), ax, + PhoneNumberField.foldCase(ax.getName().substring(0, 1))), + "folding must not be ASCII-only"); + } + + @FormTest + void everyEntryIsWellFormedAndUnique() { + Set seen = new HashSet(); + for (Country c : PhoneNumberField.getAllCountries()) { + assertEquals(2, c.getIsoCode().length(), "bad ISO code: " + c.getIsoCode()); + assertTrue(seen.add(c.getIsoCode()), "duplicate ISO code: " + c.getIsoCode()); + assertTrue(c.getDialCode().length() >= 1 && c.getDialCode().length() <= 4, + "bad calling code for " + c.getIsoCode() + ": " + c.getDialCode()); + for (int i = 0; i < c.getDialCode().length(); i++) { + char ch = c.getDialCode().charAt(i); + assertTrue(ch >= '0' && ch <= '9', "non-digit calling code: " + c.getDialCode()); + } + assertTrue(c.getName().length() > 0, "unnamed country: " + c.getIsoCode()); + } + } + + @FormTest + void theTableIsNotCopiedOutForCallersToMutate() { + Country[] first = PhoneNumberField.getAllCountries(); + Country original = first[0]; + first[0] = null; + assertSame(original, PhoneNumberField.getAllCountries()[0]); + } + + // ---- the value ---------------------------------------------------- + + @FormTest + void emptyFieldHasNoNumber() { + PhoneNumberField f = new PhoneNumberField(); + assertNull(f.getE164()); + assertFalse(f.isValid()); + assertNotNull(f.getCountry(), "a country is always selected"); + } + + @FormTest + void numberIsTheCallingCodeFollowedByWhatWasTyped() { + PhoneNumberField f = new PhoneNumberField(); + f.setCountry(PhoneNumberField.findCountry("IL")); + f.getNumberField().setText("501234567"); + assertEquals("+972501234567", f.getE164()); + assertTrue(f.isValid()); + } + + @FormTest + void separatorsTheUserTypesAreNotPartOfTheNumber() { + PhoneNumberField f = new PhoneNumberField(); + f.setCountry(PhoneNumberField.findCountry("US")); + f.getNumberField().setText("(555) 010-0123"); + assertEquals("+15550100123", f.getE164()); + assertEquals("5550100123", f.getNationalNumber()); + } + + @FormTest + void aTypedTrunkPrefixIsKeptRatherThanGuessedAt() { + // Pinned because the class documents it and an example that claimed + // otherwise shipped in this file's first draft. A leading 0 is a trunk + // prefix in Israel and part of the number in Italy; stripping it here + // would corrupt the second to normalize the first, so what the user + // typed survives and the sending service normalizes. + PhoneNumberField f = new PhoneNumberField(); + f.setCountry(PhoneNumberField.findCountry("IL")); + f.getNumberField().setText("050-123-4567"); + assertEquals("0501234567", f.getNationalNumber()); + assertEquals("+9720501234567", f.getE164()); + + f.getNumberField().setText("50-123-4567"); + assertEquals("+972501234567", f.getE164()); + } + + @FormTest + void changingCountryKeepsTheNumberThatWasTyped() { + PhoneNumberField f = new PhoneNumberField(); + f.setCountry(PhoneNumberField.findCountry("US")); + f.getNumberField().setText("5550100123"); + f.setCountry(PhoneNumberField.findCountry("GB")); + assertEquals("+445550100123", f.getE164()); + } + + @FormTest + void numberFieldAsksForThePhoneKeypad() { + assertEquals(TextArea.PHONENUMBER, new PhoneNumberField().getNumberField().getConstraint()); + } + + // ---- a number that already says which country it is for -------------- + + @FormTest + void aPastedInternationalNumberIsNotGivenASecondCallingCode() { + // the field carries the phone constraint, so the platform offers the device's + // own number here -- in international form + PhoneNumberField f = new PhoneNumberField(); + f.setCountry(PhoneNumberField.findCountry("IL")); + f.getNumberField().setText("+972501234567"); + assertEquals("+972501234567", f.getE164()); + assertEquals("501234567", f.getNationalNumber()); + assertTrue(f.isValid()); + } + + @FormTest + void aPastedNumberFromAnotherCountryIsUsedAsItStands() { + PhoneNumberField f = new PhoneNumberField(); + f.setCountry(PhoneNumberField.findCountry("US")); + f.getNumberField().setText("+44 7911 123456"); + assertEquals("+447911123456", f.getE164(), + "the selector does not apply to a number that carries its own code"); + } + + @FormTest + void aNumberNoOfferedCountryCanExpressSurvivesUnchanged() { + PhoneNumberField f = new PhoneNumberField(); + f.setCountries(new Country[]{PhoneNumberField.findCountry("US")}); + f.setE164("+447911123456"); + assertEquals("+447911123456", f.getE164(), + "storing it as a national number would read back as +1447911123456"); + assertEquals("US", f.getCountry().getIsoCode(), "and the selector is left alone"); + } + + // ---- parsing an existing number ------------------------------------ + + @FormTest + void settingAnE164NumberSelectsItsCountry() { + PhoneNumberField f = new PhoneNumberField(); + f.setE164("+972501234567"); + assertEquals("IL", f.getCountry().getIsoCode()); + assertEquals("501234567", f.getNationalNumber()); + assertEquals("+972501234567", f.getE164()); + } + + @FormTest + void assignedCallingCodesArePrefixFree() { + // the parse leans on this: no assigned code is a prefix of another, so a + // number can match at most one of them + Country[] all = PhoneNumberField.getAllCountries(); + for (Country a : all) { + for (Country b : all) { + if (!a.getDialCode().equals(b.getDialCode())) { + assertFalse(b.getDialCode().startsWith(a.getDialCode()), + b.getIsoCode() + " (+" + b.getDialCode() + ") starts with " + + a.getIsoCode() + " (+" + a.getDialCode() + ")"); + } + } + } + } + + @FormTest + void theLongestCallingCodeWinsInAnApplicationsOwnList() { + // an application's list is not bound by the prefix-free rule the assigned + // codes follow, so the longer code still has to win + PhoneNumberField f = new PhoneNumberField(); + f.setCountries(new Country[]{ + new Country("XA", "1", "Shorter"), + new Country("XB", "1242", "Longer")}); + f.setE164("+12425550123"); + assertEquals("XB", f.getCountry().getIsoCode()); + assertEquals("5550123", f.getNationalNumber()); + } + + @FormTest + void aSharedCallingCodeKeepsTheCountryAlreadySelected() { + PhoneNumberField f = new PhoneNumberField(); + f.setCountry(PhoneNumberField.findCountry("CA")); + f.setE164("+15550100123"); + assertEquals("CA", f.getCountry().getIsoCode(), + "+1 does not say which of its countries the number is from"); + } + + @FormTest + void aNumberWithNoKnownCallingCodeStillLandsInTheField() { + PhoneNumberField f = new PhoneNumberField(); + f.setE164("+9995550123"); + assertEquals("9995550123", f.getNationalNumber()); + } + + @FormTest + void settingNullClearsTheNumber() { + PhoneNumberField f = new PhoneNumberField(); + f.setE164("+972501234567"); + f.setE164(null); + assertNull(f.getE164()); + } + + // ---- validity ------------------------------------------------------ + + @FormTest + void aNumberTooShortOrTooLongIsNotValid() { + PhoneNumberField f = new PhoneNumberField(); + f.setCountry(PhoneNumberField.findCountry("IL")); + f.getNumberField().setText("123"); + assertFalse(f.isValid()); + f.getNumberField().setText("1234"); + assertTrue(f.isValid()); + // E.164 allows fifteen digits including the calling code + f.getNumberField().setText("123456789012"); + assertTrue(f.isValid()); + f.getNumberField().setText("1234567890123"); + assertFalse(f.isValid()); + } + + @FormTest + void aPastedNumberWhoseCallingCodeStartsWithZeroIsNotValid() { + // E.164 reserves zero as a first digit, so this cannot be a number however many + // digits follow. A leading zero in a NATIONAL number is a trunk prefix, which the + // field keeps on purpose -- so the rule applies only to the international form. + PhoneNumberField f = new PhoneNumberField(); + f.getNumberField().setText("+01234567"); + assertFalse(f.isValid()); + + f.setCountry(PhoneNumberField.findCountry("IL")); + f.getNumberField().setText("0501234567"); + assertTrue(f.isValid(), "a national trunk prefix is not the same thing"); + } + + // ---- narrowing the list -------------------------------------------- + + @FormTest + void anApplicationCanOfferItsOwnCountries() { + PhoneNumberField f = new PhoneNumberField(); + Country il = PhoneNumberField.findCountry("IL"); + f.setCountries(new Country[]{il}); + assertEquals(1, f.getCountries().length); + assertEquals("IL", f.getCountry().getIsoCode(), + "a selection outside the offered list is replaced"); + } + + @FormTest + void narrowingKeepsASelectionThatIsStillOffered() { + PhoneNumberField f = new PhoneNumberField(); + f.setCountry(PhoneNumberField.findCountry("GB")); + f.setCountries(new Country[]{ + PhoneNumberField.findCountry("IL"), + PhoneNumberField.findCountry("GB")}); + assertEquals("GB", f.getCountry().getIsoCode()); + } + + @FormTest + void aReplacementListSuppliesTheEntryItCarriesForTheSelectedCountry() { + // countries are equal by ISO code, so a list can carry a different object for + // the same country; keeping the old one would dial a code the selector no + // longer offers + PhoneNumberField f = new PhoneNumberField(); + f.setCountry(new Country("US", "999", "Somewhere else")); + f.getNumberField().setText("5550100"); + assertEquals("+9995550100", f.getE164()); + + f.setCountries(new Country[]{ + PhoneNumberField.findCountry("US"), + PhoneNumberField.findCountry("IL")}); + assertEquals("US", f.getCountry().getIsoCode()); + assertEquals("+15550100", f.getE164(), + "the selection must be the object the offered list carries"); + } + + @FormTest + void selectingACountryTheFieldDoesNotOfferIsRefused() { + // otherwise the selector shows a country its own list does not contain, and the + // field submits a calling code the user was never offered + PhoneNumberField f = new PhoneNumberField(); + f.setCountries(new Country[]{PhoneNumberField.findCountry("US")}); + assertThrows(IllegalArgumentException.class, + () -> f.setCountry(PhoneNumberField.findCountry("IL"))); + assertEquals("US", f.getCountry().getIsoCode(), "and the selection is untouched"); + } + + @FormTest + void anApplicationsOwnEntryForAnOfferedCountryIsKeptAsPassed() { + // countries are equal by ISO code, so this one counts as offered -- and what the + // application passed is what it gets back, overridden calling code and all + PhoneNumberField f = new PhoneNumberField(); + f.setCountry(new Country("US", "999", "Somewhere else")); + assertEquals("999", f.getCountry().getDialCode()); + } + + @FormTest + void anEmptyListIsRefused() { + PhoneNumberField f = new PhoneNumberField(); + assertThrows(IllegalArgumentException.class, () -> f.setCountries(new Country[0])); + } + + @FormTest + void nullRestoresTheFullList() { + PhoneNumberField f = new PhoneNumberField(); + f.setCountries(new Country[]{PhoneNumberField.findCountry("IL")}); + f.setCountries(null); + assertTrue(f.getCountries().length > 200); + assertEquals("IL", f.getCountry().getIsoCode(), + "a country the full list also has stays selected"); + } + + @FormTest + void restoringTheFullListDropsACountryOnlyACustomListHad() { + // the selection has to be one of the countries on offer; a country invented + // for a replaced list is not in the full one + PhoneNumberField f = new PhoneNumberField(); + f.setCountries(new Country[]{new Country("XA", "1", "Somewhere")}); + assertEquals("XA", f.getCountry().getIsoCode()); + f.setCountries(null); + assertNotNull(PhoneNumberField.findCountry(f.getCountry().getIsoCode()), + "the selected country must be one the full list carries"); + } + + @FormTest + void countriesAreEqualByIsoCode() { + assertEquals(PhoneNumberField.findCountry("IL"), new Country("IL", "972", "Anything")); + assertEquals(PhoneNumberField.findCountry("IL").hashCode(), + new Country("IL", "972", "Anything").hashCode()); + assertNotEquals(PhoneNumberField.findCountry("IL"), PhoneNumberField.findCountry("US")); + } +} diff --git a/maven/core-unittests/src/test/java/com/codename1/components/PhoneVerificationTest.java b/maven/core-unittests/src/test/java/com/codename1/components/PhoneVerificationTest.java new file mode 100644 index 00000000000..fd9f2b9639a --- /dev/null +++ b/maven/core-unittests/src/test/java/com/codename1/components/PhoneVerificationTest.java @@ -0,0 +1,464 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. All rights reserved. + * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER. + * This code is free software; you can redistribute it and/or modify it + * under the terms of the GNU General Public License version 2 only, as + * published by the Free Software Foundation. Codename One designates this + * particular file as subject to the "Classpath" exception as provided + * by Oracle in the LICENSE file that accompanied this code. + * + * This code is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License + * version 2 for more details (a copy is included in the LICENSE file that + * accompanied this code). + * + * You should have received a copy of the GNU General Public License version + * 2 along with this work; if not, write to the Free Software Foundation, + * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA. + * + * Please contact Codename One through http://www.codenameone.com/ if you + * need additional information or have any questions. + */ +package com.codename1.components; + +import com.codename1.components.PhoneVerification.Response; +import com.codename1.junit.FormTest; +import com.codename1.junit.UITestBase; +import com.codename1.ui.Form; +import com.codename1.ui.events.ActionEvent; +import com.codename1.ui.events.ActionListener; +import com.codename1.ui.layouts.BoxLayout; + +import java.util.ArrayList; +import java.util.List; +import java.util.concurrent.atomic.AtomicInteger; + +import static org.junit.jupiter.api.Assertions.*; + +/** + * Exercises {@link PhoneVerification}: the two stages, the one-shot response + * handed to the application, and what happens to an answer that arrives after + * the user has moved on. + */ +class PhoneVerificationTest extends UITestBase { + + /** Records what it was asked and answers only when the test says so. */ + private static final class RecordingSender implements PhoneVerification.CodeSender { + final List numbers = new ArrayList(); + Response pending; + + public void sendCode(String e164Number, Response response) { + numbers.add(e164Number); + pending = response; + } + } + + private static final class RecordingVerifier implements PhoneVerification.CodeVerifier { + final List codes = new ArrayList(); + String number; + Response pending; + + public void verifyCode(String e164Number, String code, Response response) { + number = e164Number; + codes.add(code); + pending = response; + } + } + + private static PhoneVerification withNumber(String iso, String national) { + PhoneVerification v = new PhoneVerification(); + v.getPhoneNumberField().setCountry(PhoneNumberField.findCountry(iso)); + v.getPhoneNumberField().getNumberField().setText(national); + return v; + } + + // ---- the number's shape ------------------------------------------ + + @FormTest + void plausibilityFollowsTheShapeOfE164() { + assertTrue(PhoneVerification.isPlausibleE164("+972501234567")); + assertTrue(PhoneVerification.isPlausibleE164("+12345")); + assertFalse(PhoneVerification.isPlausibleE164("+1234"), "four digits is too short"); + assertFalse(PhoneVerification.isPlausibleE164("+1234567890123456"), "sixteen digits is too long"); + assertFalse(PhoneVerification.isPlausibleE164("972501234567"), "no leading plus"); + assertFalse(PhoneVerification.isPlausibleE164("+97250-123456"), "not all digits"); + assertFalse(PhoneVerification.isPlausibleE164("+01234567"), + "E.164 reserves zero, so no calling code starts with one"); + assertFalse(PhoneVerification.isPlausibleE164(null)); + } + + // ---- stage one ---------------------------------------------------- + + @FormTest + void startsOnTheNumberStage() { + assertFalse(new PhoneVerification().isCodeStage()); + } + + @FormTest + void anImplausibleNumberIsNeverSentToTheServer() { + RecordingSender sender = new RecordingSender(); + PhoneVerification v = withNumber("IL", "1"); + v.setCodeSender(sender); + v.requestCode(v.getPhoneNumberField().getE164()); + assertTrue(sender.numbers.isEmpty()); + assertFalse(v.isCodeStage()); + } + + @FormTest + void aMissingSenderIsReportedRatherThanThrown() { + PhoneVerification v = withNumber("IL", "501234567"); + v.requestCode(v.getPhoneNumberField().getE164()); + assertFalse(v.isCodeStage()); + } + + @FormTest + void acceptedNumberMovesToTheCodeStage() { + RecordingSender sender = new RecordingSender(); + PhoneVerification v = withNumber("IL", "501234567"); + v.setCodeSender(sender); + v.requestCode(v.getPhoneNumberField().getE164()); + assertEquals(1, sender.numbers.size()); + assertEquals("+972501234567", sender.numbers.get(0)); + assertFalse(v.isCodeStage(), "the stage only turns once the server has answered"); + sender.pending.succeeded(); + flushSerialCalls(); + assertTrue(v.isCodeStage()); + assertEquals("+972501234567", v.getPhoneNumber()); + } + + @FormTest + void aSecondTapWhileTheServerIsThinkingSendsNothing() { + RecordingSender sender = new RecordingSender(); + PhoneVerification v = withNumber("IL", "501234567"); + v.setCodeSender(sender); + v.requestCode(v.getPhoneNumberField().getE164()); + v.requestCode(v.getPhoneNumberField().getE164()); + assertEquals(1, sender.numbers.size(), "one tap, one message"); + } + + @FormTest + void aRefusedNumberStaysOnTheNumberStageAndReportsIt() { + RecordingSender sender = new RecordingSender(); + AtomicInteger failed = new AtomicInteger(); + PhoneVerification v = withNumber("IL", "501234567"); + v.setCodeSender(sender); + v.addFailedListener(new ActionListener() { + public void actionPerformed(ActionEvent evt) { + failed.incrementAndGet(); + } + }); + v.requestCode(v.getPhoneNumberField().getE164()); + sender.pending.failed("no route to that number"); + flushSerialCalls(); + assertFalse(v.isCodeStage()); + assertEquals(1, failed.get()); + } + + // ---- stage two ------------------------------------------------------ + + @FormTest + void aFilledCodeIsVerifiedWithoutPressingAnything() { + RecordingVerifier verifier = new RecordingVerifier(); + PhoneVerification v = new PhoneVerification(); + v.setCodeVerifier(verifier); + v.showCodeStage("+972501234567"); + v.getOtpField().setText("123456"); + assertEquals(1, verifier.codes.size(), "filling the last box is the submit"); + assertEquals("123456", verifier.codes.get(0)); + assertEquals("+972501234567", verifier.number); + } + + @FormTest + void anAcceptedCodeIsReportedToTheApplication() { + RecordingVerifier verifier = new RecordingVerifier(); + AtomicInteger verified = new AtomicInteger(); + PhoneVerification v = new PhoneVerification(); + v.setCodeVerifier(verifier); + v.addVerifiedListener(new ActionListener() { + public void actionPerformed(ActionEvent evt) { + verified.incrementAndGet(); + } + }); + v.showCodeStage("+972501234567"); + v.getOtpField().setText("123456"); + assertEquals(0, verified.get(), "not before the server answers"); + verifier.pending.succeeded(); + flushSerialCalls(); + assertEquals(1, verified.get()); + } + + @FormTest + void aRejectedCodeIsClearedForAnotherTry() { + RecordingVerifier verifier = new RecordingVerifier(); + PhoneVerification v = new PhoneVerification(); + v.setCodeVerifier(verifier); + v.showCodeStage("+972501234567"); + v.getOtpField().setText("123456"); + verifier.pending.failed(null); + flushSerialCalls(); + assertEquals("", v.getOtpField().getText()); + assertTrue(v.isCodeStage(), "a wrong code does not send the user back to the number"); + } + + @FormTest + void anIncompleteCodeIsNeverSubmitted() { + RecordingVerifier verifier = new RecordingVerifier(); + PhoneVerification v = new PhoneVerification(); + v.setCodeVerifier(verifier); + v.showCodeStage("+972501234567"); + v.getOtpField().setText("123"); + v.submitCode(); + assertTrue(verifier.codes.isEmpty()); + } + + @FormTest + void changingTheNumberGoesBackAndDropsTheCode() { + PhoneVerification v = new PhoneVerification(); + v.showCodeStage("+972501234567"); + v.getOtpField().setText("1234"); + v.showNumberStage(); + assertFalse(v.isCodeStage()); + assertEquals("", v.getOtpField().getText()); + } + + @FormTest + void aRefusedSendDoesNotTakeOverTheNumberTheCodeWasSentTo() { + // a custom layout can call requestCode from the code stage with another number; + // if that send is refused the screen still describes the one that worked, so the + // code the user is looking at must still be verified against it + RecordingSender sender = new RecordingSender(); + RecordingVerifier verifier = new RecordingVerifier(); + PhoneVerification v = new PhoneVerification(); + v.setCodeSender(sender); + v.setCodeVerifier(verifier); + v.showCodeStage("+972501234567"); + + v.requestCode("+15550100123"); + sender.pending.failed("no route"); + flushSerialCalls(); + + assertEquals("+972501234567", v.getPhoneNumber(), + "the number a code was actually sent to"); + v.getOtpField().setText("123456"); + assertEquals("+972501234567", verifier.number, + "and the code is checked against that one"); + } + + @FormTest + void anAcceptedSendDoesTakeOverTheNumber() { + RecordingSender sender = new RecordingSender(); + PhoneVerification v = withNumber("IL", "501234567"); + v.setCodeSender(sender); + v.requestCode("+972501234567"); + sender.pending.succeeded(); + flushSerialCalls(); + assertEquals("+972501234567", v.getPhoneNumber()); + } + + // ---- the response contract ------------------------------------------- + + @FormTest + void aSecondAnswerToTheSameRequestIsIgnored() { + RecordingVerifier verifier = new RecordingVerifier(); + AtomicInteger verified = new AtomicInteger(); + AtomicInteger failed = new AtomicInteger(); + PhoneVerification v = new PhoneVerification(); + v.setCodeVerifier(verifier); + v.addVerifiedListener(new ActionListener() { + public void actionPerformed(ActionEvent evt) { + verified.incrementAndGet(); + } + }); + v.addFailedListener(new ActionListener() { + public void actionPerformed(ActionEvent evt) { + failed.incrementAndGet(); + } + }); + v.showCodeStage("+972501234567"); + v.getOtpField().setText("123456"); + Response r = verifier.pending; + r.succeeded(); + r.failed("changed my mind"); + r.succeeded(); + flushSerialCalls(); + assertEquals(1, verified.get()); + assertEquals(0, failed.get()); + } + + @FormTest + void anAnswerToASupersededRequestIsDropped() { + RecordingSender sender = new RecordingSender(); + PhoneVerification v = withNumber("IL", "501234567"); + v.setCodeSender(sender); + v.requestCode(v.getPhoneNumberField().getE164()); + Response stale = sender.pending; + + // the user gave up waiting, corrected the number and sent again + v.showNumberStage(); + v.getPhoneNumberField().getNumberField().setText("501111111"); + v.requestCode(v.getPhoneNumberField().getE164()); + + stale.succeeded(); + flushSerialCalls(); + assertFalse(v.isCodeStage(), "the first server's answer must not move a screen it no longer owns"); + + sender.pending.succeeded(); + flushSerialCalls(); + assertTrue(v.isCodeStage()); + assertEquals("+972501111111", v.getPhoneNumber()); + } + + @FormTest + void anAnswerArrivingAfterTheUserBackedOutIsDropped() { + RecordingVerifier verifier = new RecordingVerifier(); + AtomicInteger verified = new AtomicInteger(); + PhoneVerification v = new PhoneVerification(); + v.setCodeVerifier(verifier); + v.addVerifiedListener(new ActionListener() { + public void actionPerformed(ActionEvent evt) { + verified.incrementAndGet(); + } + }); + v.showCodeStage("+972501234567"); + v.getOtpField().setText("123456"); + + // the user gave up waiting and went back to fix the number + v.showNumberStage(); + + verifier.pending.succeeded(); + flushSerialCalls(); + assertEquals(0, verified.get(), + "a number must not be reported verified on a screen the user left"); + assertFalse(v.isCodeStage()); + } + + @FormTest + void aResendAnswerArrivingAfterTheUserBackedOutDoesNotReopenTheCodeStage() { + RecordingSender sender = new RecordingSender(); + PhoneVerification v = withNumber("IL", "501234567"); + v.setCodeSender(sender); + v.setResendDelay(0); + v.showCodeStage("+972501234567"); + v.requestCode("+972501234567"); + + v.showNumberStage(); + + sender.pending.succeeded(); + flushSerialCalls(); + assertFalse(v.isCodeStage(), + "a resend the user walked away from must not drag them back"); + } + + @FormTest + void anAnswerStillArrivesAfterTheComponentLeavesTheForm() { + // An application that shows a progress screen over the wait deinitializes this + // component while the server is still thinking. Dropping the answer then would + // lose a verification the server did give, and leave the user with nothing -- + // which is worse than a listener firing a moment after they moved on. Going + // BACK to the number stage is the case that retires a request, and it is a + // different thing: there the user said they were done waiting. + RecordingVerifier verifier = new RecordingVerifier(); + AtomicInteger verified = new AtomicInteger(); + PhoneVerification v = new PhoneVerification(); + v.setCodeVerifier(verifier); + v.addVerifiedListener(new ActionListener() { + public void actionPerformed(ActionEvent evt) { + verified.incrementAndGet(); + } + }); + Form f = new Form("t", BoxLayout.y()); + f.add(v); + f.show(); + v.showCodeStage("+972501234567"); + v.getOtpField().setText("123456"); + assertEquals(1, verifier.codes.size()); + + f.removeComponent(v); + + verifier.pending.succeeded(); + flushSerialCalls(); + assertEquals(1, verified.get(), + "the answer the server gave must still reach the application"); + } + + // ---- resend ------------------------------------------------------------ + + @FormTest + void resendIsHeldBackUntilTheDelayHasPassed() { + PhoneVerification v = new PhoneVerification(); + assertEquals(60, v.getResendDelay()); + v.showCodeStage("+972501234567"); + assertFalse(v.getResendButton().isEnabled(), "a resend offered at once invites a second SMS"); + } + + @FormTest + void startingAtTheCodeStageBeforeThereIsAFormStillOpensTheKeyboard() { + // the documented way to start at the second stage builds the component, + // calls showCodeStage and only then adds it to a form; requestFocus does + // nothing until there is one + PhoneVerification v = new PhoneVerification(); + v.showCodeStage("+972501234567"); + + Form f = new Form("t", BoxLayout.y()); + f.add(v); + f.show(); + flushSerialCalls(); + + assertTrue(v.getOtpField().getInputField().hasFocus(), + "the code field should be ready to type into"); + } + + @FormTest + void shorteningTheResendDelayAppliesToTheWaitAlreadyRunning() { + PhoneVerification v = new PhoneVerification(); + v.showCodeStage("+972501234567"); + assertFalse(v.getResendButton().isEnabled()); + + v.setResendDelay(0); + assertTrue(v.getResendButton().isEnabled(), + "setting the delay to zero offers the resend now, not at the next stage"); + } + + @FormTest + void lengtheningTheResendDelayLeavesTheWaitAlreadyRunningAlone() { + // extending a wait somebody is already serving is not something a setter + // should do behind their back + PhoneVerification v = new PhoneVerification(); + v.setResendDelay(5); + v.showCodeStage("+972501234567"); + v.setResendDelay(600); + assertEquals(600, v.getResendDelay()); + v.setResendDelay(0); + assertTrue(v.getResendButton().isEnabled()); + } + + @FormTest + void aZeroDelayOffersResendImmediately() { + PhoneVerification v = new PhoneVerification(); + v.setResendDelay(0); + v.showCodeStage("+972501234567"); + assertTrue(v.getResendButton().isEnabled()); + } + + @FormTest + void aNegativeDelayIsTreatedAsNone() { + PhoneVerification v = new PhoneVerification(); + v.setResendDelay(-5); + assertEquals(0, v.getResendDelay()); + } + + // ---- the code field carries the hint ------------------------------------ + + @FormTest + void theCodeFieldIsTheOneThatCanReceiveTheSms() { + PhoneVerification v = new PhoneVerification(); + assertNotEquals(0, + v.getOtpField().getInputField().getConstraint() & com.codename1.ui.TextArea.ONE_TIME_CODE); + } + + @FormTest + void theCodeLengthIsConfigurable() { + assertEquals(4, new PhoneVerification(4).getOtpField().getLength()); + } +} diff --git a/maven/core-unittests/src/test/java/com/codename1/ui/TextFieldConstraintMaskTest.java b/maven/core-unittests/src/test/java/com/codename1/ui/TextFieldConstraintMaskTest.java new file mode 100644 index 00000000000..2cf4d234229 --- /dev/null +++ b/maven/core-unittests/src/test/java/com/codename1/ui/TextFieldConstraintMaskTest.java @@ -0,0 +1,64 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. All rights reserved. + * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER. + * This code is free software; you can redistribute it and/or modify it + * under the terms of the GNU General Public License version 2 only, as + * published by the Free Software Foundation. Codename One designates this + * particular file as subject to the "Classpath" exception as provided + * by Oracle in the LICENSE file that accompanied this code. + * + * This code is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License + * version 2 for more details (a copy is included in the LICENSE file that + * accompanied this code). + * + * You should have received a copy of the GNU General Public License version + * 2 along with this work; if not, write to the Free Software Foundation, + * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA. + * + * Please contact Codename One through http://www.codenameone.com/ if you + * need additional information or have any questions. + */ +package com.codename1.ui; + +import com.codename1.junit.FormTest; +import com.codename1.junit.UITestBase; + +import static org.junit.jupiter.api.Assertions.*; + +/** + * A modifier bit must not turn a typed constraint back into "anything goes". + */ +class TextFieldConstraintMaskTest extends UITestBase { + + @FormTest + void aHintBesideNumericStillRestrictsToDigits() { + // the documented way to mark a code field is NUMERIC | ONE_TIME_CODE, and the + // lightweight editing path compared the whole constraint, so the hint beside the + // base type turned a digits-only field into one that took letters + TextField f = new TextField(); + f.setConstraint(TextArea.NUMERIC | TextArea.ONE_TIME_CODE); + assertTrue(f.validChar("7")); + assertFalse(f.validChar("a")); + } + + @FormTest + void everyOtherModifierBehavesTheSameWay() { + TextField f = new TextField(); + f.setConstraint(TextArea.NUMERIC | TextArea.SENSITIVE); + assertFalse(f.validChar("a")); + f.setConstraint(TextArea.PHONENUMBER | TextArea.NON_PREDICTIVE); + assertTrue(f.validChar("+")); + assertFalse(f.validChar("a")); + } + + @FormTest + void aPlainConstraintIsUnaffected() { + TextField f = new TextField(); + f.setConstraint(TextArea.ANY); + assertTrue(f.validChar("a")); + f.setConstraint(TextArea.NUMERIC); + assertFalse(f.validChar("a")); + } +} diff --git a/maven/core-unittests/src/test/java/com/codename1/ui/editor/EditorViewInputTest.java b/maven/core-unittests/src/test/java/com/codename1/ui/editor/EditorViewInputTest.java index e80983d958f..3f76bc4bd06 100644 --- a/maven/core-unittests/src/test/java/com/codename1/ui/editor/EditorViewInputTest.java +++ b/maven/core-unittests/src/test/java/com/codename1/ui/editor/EditorViewInputTest.java @@ -25,6 +25,7 @@ import com.codename1.junit.FormTest; import com.codename1.junit.UITestBase; +import com.codename1.ui.EditField; import com.codename1.ui.Form; import com.codename1.ui.TextInputClient; import com.codename1.ui.TextInputConfig; @@ -126,4 +127,24 @@ void commitTextReplacesActiveComposition() { v.commitText(KANJI); assertEquals("ab" + KANJI, v.getText()); } + + @FormTest + void aSecondCommitFollowsTheFirstRatherThanReplacingIt() { + // A commit ends the composition, and the platform bridges rely on it: UIKit's + // insertText: replaces marked text, and the iOS view clears its own marked range + // and sends a commit with no finishComposing behind it. If the composing range + // outlived the commit, the next commit would replace it instead of following it, + // and an input method that delivers a value in fragments -- dictation, + // handwriting -- could not build one up at all. + // + // It does not outlive it: the commit replaces the composed range through the + // ordinary document-change path, which clears the composition. This test exists + // because that is easy to doubt from reading commitText alone, where nothing + // clears it in view. + EditField f = new EditField(); + f.setComposingText("ab", 0); + f.commitText("ab"); + f.commitText("cd"); + assertEquals("abcd", f.getText()); + } } diff --git a/maven/javase/src/test/java/com/codename1/impl/javase/OtpFieldRenderingTest.java b/maven/javase/src/test/java/com/codename1/impl/javase/OtpFieldRenderingTest.java new file mode 100644 index 00000000000..9046ea29f0e --- /dev/null +++ b/maven/javase/src/test/java/com/codename1/impl/javase/OtpFieldRenderingTest.java @@ -0,0 +1,234 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. All rights reserved. + * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER. + * This code is free software; you can redistribute it and/or modify it + * under the terms of the GNU General Public License version 2 only, as + * published by the Free Software Foundation. Codename One designates this + * particular file as subject to the "Classpath" exception as provided + * by Oracle in the LICENSE file that accompanied this code. + * + * This code is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License + * version 2 for more details (a copy is included in the LICENSE file that + * accompanied this code). + * + * You should have received a copy of the GNU General Public License version + * 2 along with this work; if not, write to the Free Software Foundation, + * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA. + * + * Please contact Codename One through http://www.codenameone.com/ if you + * need additional information or have any questions. + */ +package com.codename1.impl.javase; + +import com.codename1.components.OtpField; +import com.codename1.testing.junit.CodenameOneTest; +import com.codename1.ui.Container; +import com.codename1.ui.Display; +import com.codename1.ui.EditField; +import com.codename1.ui.Form; +import com.codename1.ui.Image; +import com.codename1.ui.Label; +import com.codename1.ui.TextField; +import com.codename1.ui.layouts.BoxLayout; + +import org.junit.jupiter.api.Test; + +import java.awt.GraphicsEnvironment; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicReference; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.junit.jupiter.api.Assumptions.assumeFalse; + +/** + * Renders an {@link OtpField} through the real JavaSE rasterizer and reads the + * pixels back. + * + *

The field is one editor drawing nothing behind a row of boxes that draw the + * value, which is what lets a whole code arrive at once. Nothing in a unit test + * against the no-op test implementation can tell that apart from a field that + * draws nothing at all, so this asserts what a user would see: ink inside every + * box, and each digit inside its own box rather than all of them at the left.

+ */ +@CodenameOneTest +public class OtpFieldRenderingTest { + + @Test + public void everyBoxDrawsItsOwnDigit() throws Exception { + assumeFalse(GraphicsEnvironment.isHeadless(), "needs a display"); + + final AtomicReference fieldRef = new AtomicReference(); + final AtomicReference pixelsRef = new AtomicReference(); + final AtomicReference
formRef = new AtomicReference(); + + runOnCn1AndWait(new Runnable() { + public void run() { + Form f = new Form("Otp", BoxLayout.y()); + OtpField otp = new OtpField(6); + f.add(otp); + f.show(); + f.revalidate(); + otp.setText("123456"); + f.revalidate(); + fieldRef.set(otp); + formRef.set(f); + + Image img = Image.createImage(f.getWidth(), f.getHeight(), 0xffffffff); + f.paintComponent(img.getGraphics(), true); + int[] rgb = img.getRGB(); + pixelsRef.set(rgb); + } + }); + + OtpField otp = fieldRef.get(); + Form form = formRef.get(); + int[] rgb = pixelsRef.get(); + assertEquals(6, otp.getLength()); + + // Ink anywhere at all. Both mistakes that make a rendering test vacuous -- + // a form that was never shown, and a rasterizer that draws nothing -- land + // here rather than passing quietly. + assertTrue(ink(rgb, 0, 0, form.getWidth(), form.getHeight(), form.getWidth()) > 0, + "nothing was rasterized at all"); + + for (int i = 0; i < otp.getLength(); i++) { + TextField box = otp.getBox(i); + assertTrue(box.getWidth() > 0 && box.getHeight() > 0, "box " + i + " has no size"); + long boxInk = ink(rgb, box.getAbsoluteX(), box.getAbsoluteY(), + box.getWidth(), box.getHeight(), form.getWidth()); + assertTrue(boxInk > 0, "box " + i + " drew nothing; the code is not one digit per box"); + } + } + + @Test + public void anEmptyFieldDrawsBoxesButNoDigits() throws Exception { + assumeFalse(GraphicsEnvironment.isHeadless(), "needs a display"); + + final AtomicReference inkRef = new AtomicReference(); + runOnCn1AndWait(new Runnable() { + public void run() { + Form f = new Form("Otp", BoxLayout.y()); + OtpField otp = new OtpField(6); + f.add(otp); + f.show(); + f.revalidate(); + + long empty = totalInk(f, otp); + otp.setText("123456"); + f.revalidate(); + long filled = totalInk(f, otp); + inkRef.set(new long[]{empty, filled}); + } + }); + + long[] measured = inkRef.get(); + assertTrue(measured[1] > measured[0], + "a filled field must carry more ink than an empty one -- the digits are " + + "drawn by the boxes, and if the value only lived in the editor above " + + "them nothing would change"); + } + + @Test + public void theCaretLandsInsideTheActiveBoxWhenTheFieldIsNotAtTheOrigin() throws Exception { + assumeFalse(GraphicsEnvironment.isHeadless(), "needs a display"); + + // The field is deliberately given ancestors with offsets of their own. Painting runs + // with those offsets already applied to the Graphics, so a caret drawn at an absolute + // coordinate lands twice as far down the form as it should -- or outside the clip and + // nowhere at all -- and neither shows up when the field is at the origin. + final AtomicReference inkRef = new AtomicReference(); + runOnCn1AndWait(new Runnable() { + public void run() { + Form f = new Form("Otp", BoxLayout.y()); + f.add(new Label("a heading, so the field does not start at the top")); + Container padded = new Container(BoxLayout.y()); + padded.getAllStyles().setPadding(40, 10, 30, 30); + padded.getAllStyles().setMargin(20, 10, 25, 25); + OtpField otp = new OtpField(6); + padded.add(otp); + f.add(padded); + f.show(); + f.revalidate(); + + // showing the form already focuses the only focusable thing in it, so + // focus is dropped and retaken rather than assumed to start off + EditField input = otp.getInputField(); + input.setFocus(false); + f.revalidate(); + long unfocused = boxInk(f, otp, 0); + input.setFocus(true); + f.revalidate(); + long focused = boxInk(f, otp, 0); + inkRef.set(new long[]{unfocused, focused, input.hasFocus() ? 1 : 0}); + } + }); + + long[] measured = inkRef.get(); + assertEquals(1, measured[2], "the input has to hold focus for a caret to be drawn"); + assertTrue(measured[1] > measured[0], + "the caret must be drawn inside the first box; empty box ink went from " + + measured[0] + " to " + measured[1]); + } + + /// Ink inside one box of the field, painted through the whole form so every + /// ancestor translation the real hierarchy applies is in force. + private static long boxInk(Form f, OtpField otp, int index) { + Image img = Image.createImage(f.getWidth(), f.getHeight(), 0xffffffff); + f.paintComponent(img.getGraphics(), true); + TextField box = otp.getBox(index); + return ink(img.getRGB(), box.getAbsoluteX(), box.getAbsoluteY(), + box.getWidth(), box.getHeight(), f.getWidth()); + } + + private static long totalInk(Form f, OtpField otp) { + Image img = Image.createImage(f.getWidth(), f.getHeight(), 0xffffffff); + f.paintComponent(img.getGraphics(), true); + int[] rgb = img.getRGB(); + return ink(rgb, otp.getAbsoluteX(), otp.getAbsoluteY(), otp.getWidth(), otp.getHeight(), + f.getWidth()); + } + + /// Summed darkness over a rectangle, so "did anything draw here" is a number + /// rather than a guess. + private static long ink(int[] rgb, int x, int y, int w, int h, int stride) { + long total = 0; + for (int row = Math.max(0, y); row < y + h; row++) { + int base = row * stride; + for (int col = Math.max(0, x); col < x + w; col++) { + int idx = base + col; + if (idx < 0 || idx >= rgb.length) { + continue; + } + int p = rgb[idx]; + total += 255 - (p & 0xff); + total += 255 - ((p >> 8) & 0xff); + total += 255 - ((p >> 16) & 0xff); + } + } + return total; + } + + private void runOnCn1AndWait(final Runnable r) throws Exception { + final CountDownLatch latch = new CountDownLatch(1); + final AtomicReference err = new AtomicReference(); + Display.getInstance().callSerially(new Runnable() { + public void run() { + try { + r.run(); + } catch (Throwable t) { + err.set(t); + } finally { + latch.countDown(); + } + } + }); + assertTrue(latch.await(15, TimeUnit.SECONDS), "Codename One EDT work timed out"); + if (err.get() != null) { + throw new RuntimeException(err.get()); + } + } +} diff --git a/native-themes/android-material/theme.css b/native-themes/android-material/theme.css index 8b854a28ca8..3ca88b7c967 100644 --- a/native-themes/android-material/theme.css +++ b/native-themes/android-material/theme.css @@ -738,6 +738,34 @@ PopupContent { border-radius: 7mm; } +/* Phone number verification: the number field, and the code entry that renders + one box per digit. The boxes are field-shaped cells so the code reads as a row + of slots; the field that actually holds the code sits over them and draws only + the caret, which is why OtpFieldInput contributes no chrome of its own. */ +OtpField { background-color: transparent; padding: 1mm 0; margin: 0; } +OtpDigit { + cn1-derive: TextField; + text-align: center; + font-size: 4mm; + padding: 2mm 1mm; + margin: 0.5mm; +} +OtpFieldInput { background-color: transparent; padding: 0; margin: 0; } +PhoneNumberField { background-color: transparent; padding: 0; margin: 0; } +PhoneNumberCountry { cn1-derive: FlatButton; margin: 1mm 0 1mm 2mm; } +PhoneNumberText { cn1-derive: TextField; } +PhoneVerification { background-color: transparent; padding: 1mm 2mm; margin: 0; } +PhoneVerificationText { cn1-derive: Label; text-align: center; } +PhoneVerificationError { cn1-derive: Label; color: #b3261e; text-align: center; } +PhoneVerificationButton { cn1-derive: Button; text-align: center; } +PhoneVerificationLink { + cn1-derive: Label; + color: var(--accent-color, #6750a4); + background-color: transparent; + text-align: center; + padding: 1.5mm; +} + @media (prefers-color-scheme: dark) { Component { color: #e6e0e9; background-color: #141218; } Form { background-color: #141218; padding: 0; margin: 0; } @@ -875,4 +903,10 @@ PopupContent { ChatInputField { background-color: #1d1b20; color: #e6e0e9; border-radius: 3mm; } Separator { background-color: #49454f; } + + OtpDigit { color: #e6e0e9; } + PhoneNumberText { color: #e6e0e9; } + PhoneVerificationText { color: #cac4d0; } + PhoneVerificationError { color: #f2b8b5; } + PhoneVerificationLink { color: var(--accent-color-dark, #d0bcff); } } diff --git a/native-themes/ios-modern/theme.css b/native-themes/ios-modern/theme.css index 959129430a4..fef24f10a8a 100644 --- a/native-themes/ios-modern/theme.css +++ b/native-themes/ios-modern/theme.css @@ -1062,6 +1062,34 @@ PopupContent { border-radius: 4mm; } +/* Phone number verification: the number field, and the code entry that renders + one box per digit. The boxes are field-shaped cells so the code reads as a row + of slots; the field that actually holds the code sits over them and draws only + the caret, which is why OtpFieldInput contributes no chrome of its own. */ +OtpField { background-color: transparent; padding: 1mm 0; margin: 0; } +OtpDigit { + cn1-derive: TextField; + text-align: center; + font-size: 4mm; + padding: 2mm 1mm; + margin: 0.5mm; +} +OtpFieldInput { background-color: transparent; padding: 0; margin: 0; } +PhoneNumberField { background-color: transparent; padding: 0; margin: 0; } +PhoneNumberCountry { cn1-derive: FlatButton; margin: 1mm 0 1mm 2mm; } +PhoneNumberText { cn1-derive: TextField; } +PhoneVerification { background-color: transparent; padding: 1mm 2mm; margin: 0; } +PhoneVerificationText { cn1-derive: Label; text-align: center; } +PhoneVerificationError { cn1-derive: Label; color: #ff3b30; text-align: center; } +PhoneVerificationButton { cn1-derive: Button; text-align: center; } +PhoneVerificationLink { + cn1-derive: Label; + color: var(--accent-color, #007aff); + background-color: transparent; + text-align: center; + padding: 1.5mm; +} + @media (prefers-color-scheme: dark) { Component { color: #ffffff; background-color: #000000; } Form { background-color: #1c1c1e; padding: 0; margin: 0; } @@ -1261,4 +1289,10 @@ PopupContent { ChatVoiceButton { background-color: rgba(30,30,32,0.74); cn1-background-type: cn1-pill-border; color: var(--accent-color-dark, #0a84ff); padding: 1.5mm; margin: 0.5mm; } Separator { background-color: #38383a; } + + OtpDigit { background-color: #2c2c2e; color: #ffffff; } + PhoneNumberText { background-color: #2c2c2e; color: #ffffff; } + PhoneVerificationText { color: #ebebf5; } + PhoneVerificationError { color: #ff453a; } + PhoneVerificationLink { color: var(--accent-color-dark, #0a84ff); } } diff --git a/scripts/initializr/common/src/main/resources/skill/references/ui-components.md b/scripts/initializr/common/src/main/resources/skill/references/ui-components.md index 869a987d42b..bd0982686a5 100644 --- a/scripts/initializr/common/src/main/resources/skill/references/ui-components.md +++ b/scripts/initializr/common/src/main/resources/skill/references/ui-components.md @@ -132,6 +132,35 @@ Fluent builder methods: `label(String)`, `descriptionMessage(String)`, `errorMes `getField()` returns the underlying `TextField` if you need to set keyboard constraints or hook value listeners; `getText()` / `setText(String)` work directly on the wrapper. +## Phone number verification — `PhoneVerification`, `OtpField`, `PhoneNumberField` + +Verifying a phone number is two calls to YOUR server (send a code, check a code). Codename One does not send the SMS. `com.codename1.components.PhoneVerification` owns everything on the device: the number entry, the code entry, the resend countdown, the way back to a mistyped number, and the errors either call reports. + +```java +import com.codename1.components.PhoneVerification; + +PhoneVerification verify = new PhoneVerification(); +verify.setCodeSender((number, response) -> myServer.sendSms(number, response)); +verify.setCodeVerifier((number, code, response) -> myServer.check(number, code, response)); +verify.addVerifiedListener(e -> showMainScreen()); +form.add(verify); +``` + +Each server call is handed a `PhoneVerification.Response` and calls `succeeded()` or `failed(String)` once, from any thread. Until it answers, the button that started the request is disabled, and an answer to a request the user has already moved past is dropped. + +The two halves are usable on their own: + +- `OtpField(int length)` — the code, one box per digit, with `addCompleteListener` firing when the last box fills and `getText()` returning the code. +- `PhoneNumberField` — a country selector plus a number field, producing `getE164()` ("+972501234567"). Narrow the country list with `setCountries`. + +**The code fills itself in.** `OtpField` carries `TextArea.ONE_TIME_CODE`, so iOS offers the code from Messages above the keyboard and Android's autofill offers it from the SMS. Set the same constraint on any field of your own that holds a code: + +```java +TextField code = new TextField("", "Code", 6, TextArea.NUMERIC | TextArea.ONE_TIME_CODE); +``` + +Never read the SMS yourself to fill a code field. On Android that means `READ_SMS`, which Google Play restricts to messaging apps; the hint gets you the same result with no permission at all. + ## Sticky headers — `StickyHeaderContainer` CN1 has `com.codename1.components.StickyHeaderContainer`. It wraps a scrolling content pane and pins one or more header containers to the top: while you scroll, the header stays glued in place and can morph (color shift, height change, fade) using a transition you supply.