Bug 1498787 - Support seconds in datetime-local inputs r=jchen
authorRob Wu <rob@robwu.nl>
Tue, 16 Oct 2018 16:04:08 +0000
changeset 499947 d530c99c2be5dc8f5cfa39f1f965bb9751f274e2
parent 499946 355899b1b22fed0f5fcc94d5bd95b7205f94b499
child 499948 12fcdbe6ef8d97441fe879432bd664bf1c048ba4
push id1864
push userffxbld-merge
push dateMon, 03 Dec 2018 15:51:40 +0000
treeherdermozilla-release@f040763d99ad [default view] [failures only]
perfherder[talos] [build metrics] [platform microbench] (compared to previous push)
reviewersjchen
bugs1498787
milestone64.0a1
first release with
nightly linux32
nightly linux64
nightly mac
nightly win32
nightly win64
last release without
nightly linux32
nightly linux64
nightly mac
nightly win32
nightly win64
Bug 1498787 - Support seconds in datetime-local inputs r=jchen The "seconds" field is now supported for type="datetime-local". Examples, tested on Android Nougat (7.0): ``` No seconds because step is a whole minute: data:text/html,<input type="datetime-local" step="60"> No seconds because datetime is not a supported type (it is treated like input type=text by the DOM, but somehow a datepicker still appears): data:text/html,<input type="datetime" step="0"> Seconds because step is a second: data:text/html,<input type="datetime-local" step="1"> ``` The UI looks only slightly different: After "HH mm" there is now a "ss" spinner, optionally followed by AM/PM. Differential Revision: https://phabricator.services.mozilla.com/D8667
mobile/android/app/src/main/res/layout/datetime_picker.xml
mobile/android/base/java/org/mozilla/gecko/prompts/PromptInput.java
mobile/android/base/java/org/mozilla/gecko/widget/DateTimePicker.java
mobile/android/modules/InputWidgetHelper.jsm
mobile/android/modules/Prompt.jsm
--- a/mobile/android/app/src/main/res/layout/datetime_picker.xml
+++ b/mobile/android/app/src/main/res/layout/datetime_picker.xml
@@ -117,16 +117,34 @@
             android:layout_width="60dip"
             android:layout_height="wrap_content"
             android:layout_marginLeft="1dip"
             android:layout_marginRight="1dip"
             android:focusable="true"
             android:focusableInTouchMode="true"
             />
 
+        <TextView android:id="@+id/seccolon"
+            android:text="@string/colon"
+            android:layout_width="wrap_content"
+            android:layout_height="wrap_content"
+            android:layout_marginLeft="1dip"
+            android:layout_marginRight="1dip"/>
+
+        <!-- Second -->
+        <android.widget.NumberPicker
+            android:id="@+id/second"
+            android:layout_width="60dip"
+            android:layout_height="wrap_content"
+            android:layout_marginLeft="1dip"
+            android:layout_marginRight="1dip"
+            android:focusable="true"
+            android:focusableInTouchMode="true"
+            />
+
         <!-- AMPM -->
         <android.widget.NumberPicker
             android:id="@+id/ampm"
             android:layout_width="60dip"
             android:layout_height="wrap_content"
             android:layout_marginLeft="1dip"
             android:layout_marginRight="1dip"
             android:focusable="true"
--- a/mobile/android/base/java/org/mozilla/gecko/prompts/PromptInput.java
+++ b/mobile/android/base/java/org/mozilla/gecko/prompts/PromptInput.java
@@ -41,16 +41,17 @@ import android.widget.TimePicker;
 
 public abstract class PromptInput {
     protected final String mLabel;
     protected final String mType;
     protected final String mId;
     protected String mValue;
     protected final String mMinValue;
     protected final String mMaxValue;
+    protected final boolean mSecondEnabled;
     protected OnChangeListener mListener;
     protected View mView;
     public static final String LOGTAG = "GeckoPromptInput";
 
     public interface OnChangeListener {
         void onChange(PromptInput input);
     }
 
@@ -228,35 +229,59 @@ public abstract class PromptInput {
                     try {
                         calendar.setTime(new SimpleDateFormat("HH:mm").parse(mValue));
                     } catch (Exception e) { }
                 }
                 input.setCurrentHour(calendar.get(GregorianCalendar.HOUR_OF_DAY));
                 input.setCurrentMinute(calendar.get(GregorianCalendar.MINUTE));
                 mView = (View)input;
             } else if (mType.equals("datetime-local") || mType.equals("datetime")) {
-                DateTimePicker input = new DateTimePicker(context, "yyyy-MM-dd HH:mm", mValue.replace("T", " ").replace("Z", ""),
-                                                          DateTimePicker.PickersState.DATETIME,
-                                                          mMinValue.replace("T", " ").replace("Z", ""), mMaxValue.replace("T", " ").replace("Z", ""));
+                DateTimePicker input = new DateTimePicker(context, "yyyy-MM-dd'T'HH:mm:ss",
+                                                          formatDateTimeSeconds(mValue),
+                                                          mSecondEnabled ? DateTimePicker.PickersState.DATETIME_WITH_SECOND : DateTimePicker.PickersState.DATETIME,
+                                                          formatDateTimeSeconds(mMinValue),
+                                                          formatDateTimeSeconds(mMaxValue));
                 mView = (View)input;
             } else if (mType.equals("month")) {
                 DateTimePicker input = new DateTimePicker(context, "yyyy-MM", mValue,
                                                           DateTimePicker.PickersState.MONTH, mMinValue, mMaxValue);
                 mView = (View)input;
             }
 
             // Make sure the widgets will not be chopped on smaller screens (Bug 1412517)
             LinearLayout.LayoutParams parentParams = new LinearLayout.LayoutParams(
                     ViewGroup.LayoutParams.WRAP_CONTENT, ViewGroup.LayoutParams.WRAP_CONTENT);
             parentParams.gravity = Gravity.CENTER;
             mView.setLayoutParams(parentParams);
 
             return mView;
         }
 
+        private static String formatDateTimeSeconds(String dateString) {
+            // Reformat the datetime value so that it can be parsed by
+            // SimpleDateFormat ending with "HH:mm:ss".
+
+            // datetime may contain a 'Z' at the end.
+            dateString = dateString.replace("Z", "");
+
+            int i = dateString.indexOf(":"); // Separator in "HH:mm".
+            if (i == -1) {
+                // Unparseable input.
+                return dateString;
+            }
+
+            i = dateString.indexOf(":", i + 1); // Separator in "mm:ss".
+            if (i == -1) {
+                // Append seconds.
+                return dateString + ":00";
+            }
+
+            return dateString;
+        }
+
         private static String formatDateString(String dateFormat, Calendar calendar) {
             return new SimpleDateFormat(dateFormat).format(calendar.getTime());
         }
 
         @Override
         public Object getValue() {
             if (mType.equals("time")) {
                 TimePicker tp = (TimePicker)mView;
@@ -271,16 +296,19 @@ public abstract class PromptInput {
             }
             else {
                 DateTimePicker dp = (DateTimePicker) mView;
                 GregorianCalendar calendar = new GregorianCalendar();
                 calendar.setTimeInMillis(dp.getTimeInMillis());
                 if (mType.equals("week")) {
                     return formatDateString("yyyy-'W'ww", calendar);
                 } else if (mType.equals("datetime-local")) {
+                    if (mSecondEnabled) {
+                        return formatDateString("yyyy-MM-dd'T'HH:mm:ss", calendar);
+                    }
                     return formatDateString("yyyy-MM-dd'T'HH:mm", calendar);
                 } else if (mType.equals("datetime")) {
                     calendar.set(GregorianCalendar.ZONE_OFFSET, 0);
                     calendar.setTimeInMillis(dp.getTimeInMillis());
                     return formatDateString("yyyy-MM-dd'T'HH:mm'Z'", calendar);
                 } else if (mType.equals("month")) {
                     return formatDateString("yyyy-MM", calendar);
                 }
@@ -370,16 +398,19 @@ public abstract class PromptInput {
     public PromptInput(GeckoBundle obj) {
         mLabel = obj.getString("label", "");
         mType = obj.getString("type", "");
         String id = obj.getString("id", "");
         mId = TextUtils.isEmpty(id) ? mType : id;
         mValue = obj.getString("value", "");
         mMaxValue = obj.getString("max", "");
         mMinValue = obj.getString("min", "");
+
+        long timeStepInMs = obj.getLong("step", 0);
+        mSecondEnabled = (timeStepInMs % 60000) != 0;
     }
 
     public void saveCurrentInput(@NonNull final GeckoBundle userInput) {
         if (userInput.containsKey(mId)) {
             mValue = (String) userInput.get(mId);
         }
     }
 
--- a/mobile/android/base/java/org/mozilla/gecko/widget/DateTimePicker.java
+++ b/mobile/android/base/java/org/mozilla/gecko/widget/DateTimePicker.java
@@ -52,16 +52,17 @@ public class DateTimePicker extends Fram
     private static final char DATE_FORMAT_YEAR = 'y';
 
     boolean mYearEnabled = true;
     boolean mMonthEnabled = true;
     boolean mWeekEnabled;
     boolean mDayEnabled = true;
     boolean mHourEnabled = true;
     boolean mMinuteEnabled = true;
+    boolean mSecondEnabled = true;
     boolean mIs12HourMode;
     private boolean mCalendarEnabled;
 
     // Size of the screen in inches;
     private final int mScreenWidth;
     private final int mScreenHeight;
     private final OnValueChangeListener mOnChangeListener;
     private final LinearLayout mPickers;
@@ -69,37 +70,39 @@ public class DateTimePicker extends Fram
     private final LinearLayout mTimeSpinners;
 
     final NumberPicker mDaySpinner;
     final NumberPicker mMonthSpinner;
     final NumberPicker mWeekSpinner;
     final NumberPicker mYearSpinner;
     final NumberPicker mHourSpinner;
     final NumberPicker mMinuteSpinner;
+    final NumberPicker mSecondSpinner;
     final NumberPicker mAMPMSpinner;
     private final CalendarView mCalendar;
     private final EditText mDaySpinnerInput;
     private final EditText mMonthSpinnerInput;
     private final EditText mWeekSpinnerInput;
     private final EditText mYearSpinnerInput;
     private final EditText mHourSpinnerInput;
     private final EditText mMinuteSpinnerInput;
+    private final EditText mSecondSpinnerInput;
     private final EditText mAMPMSpinnerInput;
     private Locale mCurrentLocale;
     private String[] mShortMonths;
     private String[] mShortAMPMs;
     private int mNumberOfMonths;
 
     Calendar mTempDate;
     Calendar mCurrentDate;
     private Calendar mMinDate;
     private Calendar mMaxDate;
     private final PickersState mState;
 
-    public static enum PickersState { DATE, MONTH, WEEK, TIME, DATETIME };
+    public static enum PickersState { DATE, MONTH, WEEK, TIME, DATETIME, DATETIME_WITH_SECOND };
 
     public class OnValueChangeListener implements NumberPicker.OnValueChangeListener {
         @Override
         public void onValueChange(NumberPicker picker, int oldVal, int newVal) {
             updateInputState();
             mTempDate.setTimeInMillis(mCurrentDate.getTimeInMillis());
             if (DEBUG) {
                 Log.d(LOGTAG, "SDK version > 10, using new behavior");
@@ -131,16 +134,18 @@ public class DateTimePicker extends Fram
             } else if (picker == mHourSpinner && mHourEnabled) {
                 if (mIs12HourMode) {
                     setTempDate(Calendar.HOUR, oldVal, newVal, 1, 12);
                 } else {
                     setTempDate(Calendar.HOUR_OF_DAY, oldVal, newVal, 0, 23);
                 }
             } else if (picker == mMinuteSpinner && mMinuteEnabled) {
                 setTempDate(Calendar.MINUTE, oldVal, newVal, 0, 59);
+            } else if (picker == mSecondSpinner && mSecondEnabled) {
+                setTempDate(Calendar.SECOND, oldVal, newVal, 0, 59);
             } else if (picker == mAMPMSpinner && mHourEnabled) {
                 mTempDate.set(Calendar.AM_PM, newVal);
             } else {
                 throw new IllegalArgumentException();
             }
             setDate(mTempDate);
             if (mDayEnabled) {
                 mDaySpinner.setMaxValue(mCurrentDate.getActualMaximum(Calendar.DAY_OF_MONTH));
@@ -179,21 +184,26 @@ public class DateTimePicker extends Fram
             return mFmt.toString();
         }
     };
 
     private void displayPickers() {
         setWeekShown(false);
         set12HourShown(mIs12HourMode);
         if (mState == PickersState.DATETIME) {
+            setSecondShown(false);
+            return;
+        }
+        if (mState == PickersState.DATETIME_WITH_SECOND) {
             return;
         }
 
         setHourShown(false);
         setMinuteShown(false);
+        setSecondShown(false);
         if (mState == PickersState.WEEK) {
             setDayShown(false);
             setMonthShown(false);
             setWeekShown(true);
         } else if (mState == PickersState.MONTH) {
             setDayShown(false);
         }
     }
@@ -278,17 +288,17 @@ public class DateTimePicker extends Fram
         // a sensible default date here.
         if (mTempDate.before(mMinDate) || mTempDate.after(mMaxDate)) {
             mTempDate.setTimeInMillis(mMinDate.getTimeInMillis());
         }
 
         // If we're displaying a date, the screen is wide enough
         // (and if we're using an SDK where the calendar view exists)
         // then display a calendar.
-        if (mState == PickersState.DATE || mState == PickersState.DATETIME) {
+        if (mState == PickersState.DATE || mState == PickersState.DATETIME || mState == PickersState.DATETIME_WITH_SECOND) {
             mCalendar = new CalendarView(context);
             mCalendar.setVisibility(GONE);
 
             // Modify the time of mMaxDate and mMinDate to the end of the date and the beginning of the date. (Bug 1339884)
             mMaxDate.set(Calendar.HOUR, 23);
             mMaxDate.set(Calendar.MINUTE, 59);
             mMaxDate.set(Calendar.SECOND, 59);
             mMaxDate.set(Calendar.MILLISECOND, 999);
@@ -323,17 +333,17 @@ public class DateTimePicker extends Fram
             }
 
             mPickers.addView(mCalendar, LayoutParams.MATCH_PARENT, height);
 
         } else {
             // If the screen is more wide than high, we are displaying day and
             // time spinners, and if there is no calendar displayed, we should
             // display the fields in one row.
-            if (mScreenWidth > mScreenHeight && mState == PickersState.DATETIME) {
+            if (mScreenWidth > mScreenHeight && (mState == PickersState.DATETIME || mState == PickersState.DATETIME_WITH_SECOND)) {
                 mPickers.setOrientation(LinearLayout.HORIZONTAL);
             }
             mCalendar = null;
         }
 
         // Initialize all spinners.
         mDaySpinner = setupSpinner(R.id.day, 1,
                                    mTempDate.get(Calendar.DAY_OF_MONTH));
@@ -370,16 +380,20 @@ public class DateTimePicker extends Fram
 
         mHourSpinner.setFormatter(TWO_DIGIT_FORMATTER);
         mHourSpinnerInput = (EditText) mHourSpinner.getChildAt(1);
 
         mMinuteSpinner = setupSpinner(R.id.minute, 0, 59);
         mMinuteSpinner.setFormatter(TWO_DIGIT_FORMATTER);
         mMinuteSpinnerInput = (EditText) mMinuteSpinner.getChildAt(1);
 
+        mSecondSpinner = setupSpinner(R.id.second, 0, 59);
+        mSecondSpinner.setFormatter(TWO_DIGIT_FORMATTER);
+        mSecondSpinnerInput = (EditText) mSecondSpinner.getChildAt(1);
+
         // The order in which the spinners are displayed are locale-dependent
         reorderDateSpinners();
 
         // Set the date to the initial date. Since this date can come from the user,
         // it can fire an exception (out-of-bound date)
         try {
           updateDate(mTempDate);
         } catch (Exception ex) {
@@ -448,16 +462,19 @@ public class DateTimePicker extends Fram
             mDaySpinnerInput.clearFocus();
             inputMethodManager.hideSoftInputFromWindow(getWindowToken(), 0);
         } else if (mHourEnabled && inputMethodManager.isActive(mHourSpinnerInput)) {
             mHourSpinnerInput.clearFocus();
             inputMethodManager.hideSoftInputFromWindow(getWindowToken(), 0);
         } else if (mMinuteEnabled && inputMethodManager.isActive(mMinuteSpinnerInput)) {
             mMinuteSpinnerInput.clearFocus();
             inputMethodManager.hideSoftInputFromWindow(getWindowToken(), 0);
+        } else if (mSecondEnabled && inputMethodManager.isActive(mSecondSpinnerInput)) {
+            mSecondSpinnerInput.clearFocus();
+            inputMethodManager.hideSoftInputFromWindow(getWindowToken(), 0);
         }
     }
 
     void updateSpinners() {
         if (mDayEnabled) {
             if (mCurrentDate.equals(mMinDate)) {
                 mDaySpinner.setMinValue(mCurrentDate.get(Calendar.DAY_OF_MONTH));
                 mDaySpinner.setMaxValue(mCurrentDate.getActualMaximum(Calendar.DAY_OF_MONTH));
@@ -509,30 +526,33 @@ public class DateTimePicker extends Fram
                 mAMPMSpinner.setDisplayedValues(mShortAMPMs);
             } else {
                 mHourSpinner.setValue(mCurrentDate.get(Calendar.HOUR_OF_DAY));
             }
         }
         if (mMinuteEnabled) {
             mMinuteSpinner.setValue(mCurrentDate.get(Calendar.MINUTE));
         }
+        if (mSecondEnabled) {
+            mSecondSpinner.setValue(mCurrentDate.get(Calendar.SECOND));
+        }
     }
 
     void updateCalendar() {
         if (mCalendarEnabled) {
             mCalendar.setDate(mCurrentDate.getTimeInMillis(), false, false);
         }
     }
 
     void notifyDateChanged() {
         sendAccessibilityEvent(AccessibilityEvent.TYPE_VIEW_SELECTED);
     }
 
     public void toggleCalendar(boolean shown) {
-        if ((mState != PickersState.DATE && mState != PickersState.DATETIME)) {
+        if (mState != PickersState.DATE && mState != PickersState.DATETIME && mState != PickersState.DATETIME_WITH_SECOND) {
             return;
         }
 
         if (shown) {
             mCalendarEnabled = true;
             mCalendar.setVisibility(VISIBLE);
             setYearShown(false);
             setWeekShown(false);
@@ -619,16 +639,28 @@ public class DateTimePicker extends Fram
             mMinuteEnabled = true;
         } else {
             mMinuteSpinner.setVisibility(GONE);
             mTimeSpinners.findViewById(R.id.mincolon).setVisibility(GONE);
             mMinuteEnabled = false;
         }
     }
 
+    private void setSecondShown(boolean shown) {
+        if (shown) {
+            mSecondSpinner.setVisibility(VISIBLE);
+            mTimeSpinners.findViewById(R.id.seccolon).setVisibility(VISIBLE);
+            mSecondEnabled = true;
+        } else {
+            mSecondSpinner.setVisibility(GONE);
+            mTimeSpinners.findViewById(R.id.seccolon).setVisibility(GONE);
+            mSecondEnabled = false;
+        }
+    }
+
     private void setCurrentLocale(Locale locale) {
         if (locale.equals(mCurrentLocale)) {
             return;
         }
 
         mCurrentLocale = locale;
         mIs12HourMode = !DateFormat.is24HourFormat(getContext());
         mTempDate = getCalendarForLocale(mTempDate, locale);
--- a/mobile/android/modules/InputWidgetHelper.jsm
+++ b/mobile/android/modules/InputWidgetHelper.jsm
@@ -46,16 +46,17 @@ var InputWidgetHelper = {
       buttons: [
         this.strings().GetStringFromName("inputWidgetHelper.set"),
         this.strings().GetStringFromName("inputWidgetHelper.clear"),
         this.strings().GetStringFromName("inputWidgetHelper.cancel")
       ],
     }).addDatePicker({
       value: aElement.value,
       type: type,
+      step: this._getInputTimeStep(aElement),
       min: aElement.min,
       max: aElement.max,
     }).show(data => {
       let changed = false;
       if (data.button == -1) {
         // This type is not supported with this android version.
         return;
       }
@@ -105,10 +106,25 @@ var InputWidgetHelper = {
     let currentElement = aElement;
     while (currentElement) {
       if (currentElement.disabled)
         return true;
 
       currentElement = currentElement.parentElement;
     }
     return false;
+  },
+
+  // The step in milliseconds.
+  _getInputTimeStep: function(aElement) {
+    try {
+      // Delegate the implementation to HTMLInputElement::GetStep.
+      let tmpInput = aElement.ownerDocument.createElement("input");
+      tmpInput.type = aElement.type;
+      tmpInput.step = aElement.step;
+      // May throw if the type is unsupported.
+      tmpInput.stepUp();
+      return tmpInput.valueAsNumber || 0; // Prefer 0 over NaN.
+    } catch (e) {
+      return 0;
+    }
   }
 };
--- a/mobile/android/modules/Prompt.jsm
+++ b/mobile/android/modules/Prompt.jsm
@@ -125,16 +125,17 @@ Prompt.prototype = {
     });
   },
 
   addDatePicker: function(aOptions) {
     return this._addInput({
       type: aOptions.type || "date",
       value: aOptions.value,
       id: aOptions.id,
+      step: aOptions.step,
       max: aOptions.max,
       min: aOptions.min
     });
   },
 
   addColorPicker: function(aOptions) {
     return this._addInput({
       type: "color",