1 /*
2  * Copyright (C) 2014 The Android Open Source Project
3  *
4  * Licensed under the Apache License, Version 2.0 (the "License");
5  * you may not use this file except in compliance with the License.
6  * You may obtain a copy of the License at
7  *
8  *      http://www.apache.org/licenses/LICENSE-2.0
9  *
10  * Unless required by applicable law or agreed to in writing, software
11  * distributed under the License is distributed on an "AS IS" BASIS,
12  * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13  * See the License for the specific language governing permissions and
14  * limitations under the License.
15  */
16 
17 package android.widget;
18 
19 import android.annotation.Nullable;
20 import android.content.Context;
21 import android.content.res.ColorStateList;
22 import android.content.res.Configuration;
23 import android.content.res.Resources;
24 import android.content.res.TypedArray;
25 import android.icu.text.DateFormat;
26 import android.icu.text.DisplayContext;
27 import android.icu.util.Calendar;
28 import android.os.Parcelable;
29 import android.util.AttributeSet;
30 import android.util.StateSet;
31 import android.view.HapticFeedbackConstants;
32 import android.view.LayoutInflater;
33 import android.view.View;
34 import android.view.View.OnClickListener;
35 import android.view.ViewGroup;
36 import android.view.accessibility.AccessibilityEvent;
37 import android.widget.DayPickerView.OnDaySelectedListener;
38 import android.widget.YearPickerView.OnYearSelectedListener;
39 
40 import com.android.internal.R;
41 
42 import java.util.Locale;
43 
44 /**
45  * A delegate for picking up a date (day / month / year).
46  */
47 class DatePickerCalendarDelegate extends DatePicker.AbstractDatePickerDelegate {
48     private static final int USE_LOCALE = 0;
49 
50     private static final int UNINITIALIZED = -1;
51     private static final int VIEW_MONTH_DAY = 0;
52     private static final int VIEW_YEAR = 1;
53 
54     private static final int DEFAULT_START_YEAR = 1900;
55     private static final int DEFAULT_END_YEAR = 2100;
56 
57     private static final int ANIMATION_DURATION = 300;
58 
59     private static final int[] ATTRS_TEXT_COLOR = new int[] {
60             com.android.internal.R.attr.textColor};
61     private static final int[] ATTRS_DISABLED_ALPHA = new int[] {
62             com.android.internal.R.attr.disabledAlpha};
63 
64     private DateFormat mYearFormat;
65     private DateFormat mMonthDayFormat;
66 
67     // Top-level container.
68     private ViewGroup mContainer;
69 
70     // Header views.
71     private TextView mHeaderYear;
72     private TextView mHeaderMonthDay;
73 
74     // Picker views.
75     private ViewAnimator mAnimator;
76     private DayPickerView mDayPickerView;
77     private YearPickerView mYearPickerView;
78 
79     // Accessibility strings.
80     private String mSelectDay;
81     private String mSelectYear;
82 
83     private int mCurrentView = UNINITIALIZED;
84 
85     private final Calendar mTempDate;
86     private final Calendar mMinDate;
87     private final Calendar mMaxDate;
88 
89     private int mFirstDayOfWeek = USE_LOCALE;
90 
DatePickerCalendarDelegate(DatePicker delegator, Context context, AttributeSet attrs, int defStyleAttr, int defStyleRes)91     public DatePickerCalendarDelegate(DatePicker delegator, Context context, AttributeSet attrs,
92             int defStyleAttr, int defStyleRes) {
93         super(delegator, context);
94 
95         final Locale locale = mCurrentLocale;
96         mCurrentDate = Calendar.getInstance(locale);
97         mTempDate = Calendar.getInstance(locale);
98         mMinDate = Calendar.getInstance(locale);
99         mMaxDate = Calendar.getInstance(locale);
100 
101         mMinDate.set(DEFAULT_START_YEAR, Calendar.JANUARY, 1);
102         mMaxDate.set(DEFAULT_END_YEAR, Calendar.DECEMBER, 31);
103 
104         final Resources res = mDelegator.getResources();
105         final TypedArray a = mContext.obtainStyledAttributes(attrs,
106                 R.styleable.DatePicker, defStyleAttr, defStyleRes);
107         final LayoutInflater inflater = (LayoutInflater) mContext.getSystemService(
108                 Context.LAYOUT_INFLATER_SERVICE);
109         final int layoutResourceId = a.getResourceId(
110                 R.styleable.DatePicker_internalLayout, R.layout.date_picker_material);
111 
112         // Set up and attach container.
113         mContainer = (ViewGroup) inflater.inflate(layoutResourceId, mDelegator, false);
114         mContainer.setSaveFromParentEnabled(false);
115         mDelegator.addView(mContainer);
116 
117         // Set up header views.
118         final ViewGroup header = mContainer.findViewById(R.id.date_picker_header);
119         mHeaderYear = header.findViewById(R.id.date_picker_header_year);
120         mHeaderYear.setOnClickListener(mOnHeaderClickListener);
121         mHeaderMonthDay = header.findViewById(R.id.date_picker_header_date);
122         mHeaderMonthDay.setOnClickListener(mOnHeaderClickListener);
123 
124         // For the sake of backwards compatibility, attempt to extract the text
125         // color from the header month text appearance. If it's set, we'll let
126         // that override the "real" header text color.
127         ColorStateList headerTextColor = null;
128 
129         @SuppressWarnings("deprecation")
130         final int monthHeaderTextAppearance = a.getResourceId(
131                 R.styleable.DatePicker_headerMonthTextAppearance, 0);
132         if (monthHeaderTextAppearance != 0) {
133             final TypedArray textAppearance = mContext.obtainStyledAttributes(null,
134                     ATTRS_TEXT_COLOR, 0, monthHeaderTextAppearance);
135             final ColorStateList legacyHeaderTextColor = textAppearance.getColorStateList(0);
136             headerTextColor = applyLegacyColorFixes(legacyHeaderTextColor);
137             textAppearance.recycle();
138         }
139 
140         if (headerTextColor == null) {
141             headerTextColor = a.getColorStateList(R.styleable.DatePicker_headerTextColor);
142         }
143 
144         if (headerTextColor != null) {
145             mHeaderYear.setTextColor(headerTextColor);
146             mHeaderMonthDay.setTextColor(headerTextColor);
147         }
148 
149         // Set up header background, if available.
150         if (a.hasValueOrEmpty(R.styleable.DatePicker_headerBackground)) {
151             header.setBackground(a.getDrawable(R.styleable.DatePicker_headerBackground));
152         }
153 
154         a.recycle();
155 
156         // Set up picker container.
157         mAnimator = mContainer.findViewById(R.id.animator);
158 
159         // Set up day picker view.
160         mDayPickerView = mAnimator.findViewById(R.id.date_picker_day_picker);
161         mDayPickerView.setFirstDayOfWeek(mFirstDayOfWeek);
162         mDayPickerView.setMinDate(mMinDate.getTimeInMillis());
163         mDayPickerView.setMaxDate(mMaxDate.getTimeInMillis());
164         mDayPickerView.setDate(mCurrentDate.getTimeInMillis());
165         mDayPickerView.setOnDaySelectedListener(mOnDaySelectedListener);
166 
167         // Set up year picker view.
168         mYearPickerView = mAnimator.findViewById(R.id.date_picker_year_picker);
169         mYearPickerView.setRange(mMinDate, mMaxDate);
170         mYearPickerView.setYear(mCurrentDate.get(Calendar.YEAR));
171         mYearPickerView.setOnYearSelectedListener(mOnYearSelectedListener);
172 
173         // Set up content descriptions.
174         mSelectDay = res.getString(R.string.select_day);
175         mSelectYear = res.getString(R.string.select_year);
176 
177         // Initialize for current locale. This also initializes the date, so no
178         // need to call onDateChanged.
179         onLocaleChanged(mCurrentLocale);
180 
181         setCurrentView(VIEW_MONTH_DAY);
182     }
183 
184     /**
185      * The legacy text color might have been poorly defined. Ensures that it
186      * has an appropriate activated state, using the selected state if one
187      * exists or modifying the default text color otherwise.
188      *
189      * @param color a legacy text color, or {@code null}
190      * @return a color state list with an appropriate activated state, or
191      *         {@code null} if a valid activated state could not be generated
192      */
193     @Nullable
applyLegacyColorFixes(@ullable ColorStateList color)194     private ColorStateList applyLegacyColorFixes(@Nullable ColorStateList color) {
195         if (color == null || color.hasState(R.attr.state_activated)) {
196             return color;
197         }
198 
199         final int activatedColor;
200         final int defaultColor;
201         if (color.hasState(R.attr.state_selected)) {
202             activatedColor = color.getColorForState(StateSet.get(
203                     StateSet.VIEW_STATE_ENABLED | StateSet.VIEW_STATE_SELECTED), 0);
204             defaultColor = color.getColorForState(StateSet.get(
205                     StateSet.VIEW_STATE_ENABLED), 0);
206         } else {
207             activatedColor = color.getDefaultColor();
208 
209             // Generate a non-activated color using the disabled alpha.
210             final TypedArray ta = mContext.obtainStyledAttributes(ATTRS_DISABLED_ALPHA);
211             final float disabledAlpha = ta.getFloat(0, 0.30f);
212             ta.recycle();
213             defaultColor = multiplyAlphaComponent(activatedColor, disabledAlpha);
214         }
215 
216         if (activatedColor == 0 || defaultColor == 0) {
217             // We somehow failed to obtain the colors.
218             return null;
219         }
220 
221         final int[][] stateSet = new int[][] {{ R.attr.state_activated }, {}};
222         final int[] colors = new int[] { activatedColor, defaultColor };
223         return new ColorStateList(stateSet, colors);
224     }
225 
multiplyAlphaComponent(int color, float alphaMod)226     private int multiplyAlphaComponent(int color, float alphaMod) {
227         final int srcRgb = color & 0xFFFFFF;
228         final int srcAlpha = (color >> 24) & 0xFF;
229         final int dstAlpha = (int) (srcAlpha * alphaMod + 0.5f);
230         return srcRgb | (dstAlpha << 24);
231     }
232 
233     /**
234      * Listener called when the user selects a day in the day picker view.
235      */
236     private final OnDaySelectedListener mOnDaySelectedListener = new OnDaySelectedListener() {
237         @Override
238         public void onDaySelected(DayPickerView view, Calendar day) {
239             mCurrentDate.setTimeInMillis(day.getTimeInMillis());
240             onDateChanged(true, true);
241         }
242     };
243 
244     /**
245      * Listener called when the user selects a year in the year picker view.
246      */
247     private final OnYearSelectedListener mOnYearSelectedListener = new OnYearSelectedListener() {
248         @Override
249         public void onYearChanged(YearPickerView view, int year) {
250             // If the newly selected month / year does not contain the
251             // currently selected day number, change the selected day number
252             // to the last day of the selected month or year.
253             // e.g. Switching from Mar to Apr when Mar 31 is selected -> Apr 30
254             // e.g. Switching from 2012 to 2013 when Feb 29, 2012 is selected -> Feb 28, 2013
255             final int day = mCurrentDate.get(Calendar.DAY_OF_MONTH);
256             final int month = mCurrentDate.get(Calendar.MONTH);
257             final int daysInMonth = getDaysInMonth(month, year);
258             if (day > daysInMonth) {
259                 mCurrentDate.set(Calendar.DAY_OF_MONTH, daysInMonth);
260             }
261 
262             mCurrentDate.set(Calendar.YEAR, year);
263             if (mCurrentDate.compareTo(mMinDate) < 0) {
264                 mCurrentDate.setTimeInMillis(mMinDate.getTimeInMillis());
265             } else if (mCurrentDate.compareTo(mMaxDate) > 0) {
266                 mCurrentDate.setTimeInMillis(mMaxDate.getTimeInMillis());
267             }
268             onDateChanged(true, true);
269 
270             // Automatically switch to day picker.
271             setCurrentView(VIEW_MONTH_DAY);
272 
273             // Switch focus back to the year text.
274             mHeaderYear.requestFocus();
275         }
276     };
277 
278     /**
279      * Listener called when the user clicks on a header item.
280      */
281     private final OnClickListener mOnHeaderClickListener = v -> {
282         tryVibrate();
283 
284         switch (v.getId()) {
285             case R.id.date_picker_header_year:
286                 setCurrentView(VIEW_YEAR);
287                 break;
288             case R.id.date_picker_header_date:
289                 setCurrentView(VIEW_MONTH_DAY);
290                 break;
291         }
292     };
293 
294     @Override
onLocaleChanged(Locale locale)295     protected void onLocaleChanged(Locale locale) {
296         final TextView headerYear = mHeaderYear;
297         if (headerYear == null) {
298             // Abort, we haven't initialized yet. This method will get called
299             // again later after everything has been set up.
300             return;
301         }
302 
303         // Update the date formatter.
304         mMonthDayFormat = DateFormat.getInstanceForSkeleton("EMMMd", locale);
305         // The use of CAPITALIZATION_FOR_BEGINNING_OF_SENTENCE instead of
306         // CAPITALIZATION_FOR_STANDALONE is to address
307         // https://unicode-org.atlassian.net/browse/ICU-21631
308         // TODO(b/229287642): Switch back to CAPITALIZATION_FOR_STANDALONE
309         mMonthDayFormat.setContext(DisplayContext.CAPITALIZATION_FOR_BEGINNING_OF_SENTENCE);
310         mYearFormat = DateFormat.getInstanceForSkeleton("y", locale);
311 
312         // Update the header text.
313         onCurrentDateChanged(false);
314     }
315 
onCurrentDateChanged(boolean announce)316     private void onCurrentDateChanged(boolean announce) {
317         if (mHeaderYear == null) {
318             // Abort, we haven't initialized yet. This method will get called
319             // again later after everything has been set up.
320             return;
321         }
322 
323         final String year = mYearFormat.format(mCurrentDate.getTime());
324         mHeaderYear.setText(year);
325 
326         final String monthDay = mMonthDayFormat.format(mCurrentDate.getTime());
327         mHeaderMonthDay.setText(monthDay);
328 
329         // TODO: This should use live regions.
330         if (announce) {
331             mAnimator.announceForAccessibility(getFormattedCurrentDate());
332         }
333     }
334 
setCurrentView(final int viewIndex)335     private void setCurrentView(final int viewIndex) {
336         switch (viewIndex) {
337             case VIEW_MONTH_DAY:
338                 mDayPickerView.setDate(mCurrentDate.getTimeInMillis());
339 
340                 if (mCurrentView != viewIndex) {
341                     mHeaderMonthDay.setActivated(true);
342                     mHeaderYear.setActivated(false);
343                     mAnimator.setDisplayedChild(VIEW_MONTH_DAY);
344                     mCurrentView = viewIndex;
345                 }
346 
347                 mAnimator.announceForAccessibility(mSelectDay);
348                 break;
349             case VIEW_YEAR:
350                 final int year = mCurrentDate.get(Calendar.YEAR);
351                 mYearPickerView.setYear(year);
352                 mYearPickerView.post(() -> {
353                     mYearPickerView.requestFocus();
354                     final View selected = mYearPickerView.getSelectedView();
355                     if (selected != null) {
356                         selected.requestFocus();
357                     }
358                 });
359 
360                 if (mCurrentView != viewIndex) {
361                     mHeaderMonthDay.setActivated(false);
362                     mHeaderYear.setActivated(true);
363                     mAnimator.setDisplayedChild(VIEW_YEAR);
364                     mCurrentView = viewIndex;
365                 }
366 
367                 mAnimator.announceForAccessibility(mSelectYear);
368                 break;
369         }
370     }
371 
372     @Override
init(int year, int month, int dayOfMonth, DatePicker.OnDateChangedListener callBack)373     public void init(int year, int month, int dayOfMonth,
374             DatePicker.OnDateChangedListener callBack) {
375         setDate(year, month, dayOfMonth);
376         onDateChanged(false, false);
377 
378         mOnDateChangedListener = callBack;
379     }
380 
381     @Override
updateDate(int year, int month, int dayOfMonth)382     public void updateDate(int year, int month, int dayOfMonth) {
383         setDate(year, month, dayOfMonth);
384         onDateChanged(false, true);
385     }
386 
setDate(int year, int month, int dayOfMonth)387     private void setDate(int year, int month, int dayOfMonth) {
388         mCurrentDate.set(Calendar.YEAR, year);
389         mCurrentDate.set(Calendar.MONTH, month);
390         mCurrentDate.set(Calendar.DAY_OF_MONTH, dayOfMonth);
391         resetAutofilledValue();
392     }
393 
onDateChanged(boolean fromUser, boolean callbackToClient)394     private void onDateChanged(boolean fromUser, boolean callbackToClient) {
395         final int year = mCurrentDate.get(Calendar.YEAR);
396 
397         if (callbackToClient
398                 && (mOnDateChangedListener != null || mAutoFillChangeListener != null)) {
399             final int monthOfYear = mCurrentDate.get(Calendar.MONTH);
400             final int dayOfMonth = mCurrentDate.get(Calendar.DAY_OF_MONTH);
401             if (mOnDateChangedListener != null) {
402                 mOnDateChangedListener.onDateChanged(mDelegator, year, monthOfYear, dayOfMonth);
403             }
404             if (mAutoFillChangeListener != null) {
405                 mAutoFillChangeListener.onDateChanged(mDelegator, year, monthOfYear, dayOfMonth);
406             }
407         }
408 
409         mDayPickerView.setDate(mCurrentDate.getTimeInMillis());
410         mYearPickerView.setYear(year);
411 
412         onCurrentDateChanged(fromUser);
413 
414         if (fromUser) {
415             tryVibrate();
416         }
417     }
418 
419     @Override
getYear()420     public int getYear() {
421         return mCurrentDate.get(Calendar.YEAR);
422     }
423 
424     @Override
getMonth()425     public int getMonth() {
426         return mCurrentDate.get(Calendar.MONTH);
427     }
428 
429     @Override
getDayOfMonth()430     public int getDayOfMonth() {
431         return mCurrentDate.get(Calendar.DAY_OF_MONTH);
432     }
433 
434     @Override
setMinDate(long minDate)435     public void setMinDate(long minDate) {
436         mTempDate.setTimeInMillis(minDate);
437         if (mTempDate.get(Calendar.YEAR) == mMinDate.get(Calendar.YEAR)
438                 && mTempDate.get(Calendar.DAY_OF_YEAR) == mMinDate.get(Calendar.DAY_OF_YEAR)) {
439             // Same day, no-op.
440             return;
441         }
442         if (mCurrentDate.before(mTempDate)) {
443             mCurrentDate.setTimeInMillis(minDate);
444             onDateChanged(false, true);
445         }
446         mMinDate.setTimeInMillis(minDate);
447         mDayPickerView.setMinDate(minDate);
448         mYearPickerView.setRange(mMinDate, mMaxDate);
449     }
450 
451     @Override
getMinDate()452     public Calendar getMinDate() {
453         return mMinDate;
454     }
455 
456     @Override
setMaxDate(long maxDate)457     public void setMaxDate(long maxDate) {
458         mTempDate.setTimeInMillis(maxDate);
459         if (mTempDate.get(Calendar.YEAR) == mMaxDate.get(Calendar.YEAR)
460                 && mTempDate.get(Calendar.DAY_OF_YEAR) == mMaxDate.get(Calendar.DAY_OF_YEAR)) {
461             // Same day, no-op.
462             return;
463         }
464         if (mCurrentDate.after(mTempDate)) {
465             mCurrentDate.setTimeInMillis(maxDate);
466             onDateChanged(false, true);
467         }
468         mMaxDate.setTimeInMillis(maxDate);
469         mDayPickerView.setMaxDate(maxDate);
470         mYearPickerView.setRange(mMinDate, mMaxDate);
471     }
472 
473     @Override
getMaxDate()474     public Calendar getMaxDate() {
475         return mMaxDate;
476     }
477 
478     @Override
setFirstDayOfWeek(int firstDayOfWeek)479     public void setFirstDayOfWeek(int firstDayOfWeek) {
480         mFirstDayOfWeek = firstDayOfWeek;
481 
482         mDayPickerView.setFirstDayOfWeek(firstDayOfWeek);
483     }
484 
485     @Override
getFirstDayOfWeek()486     public int getFirstDayOfWeek() {
487         if (mFirstDayOfWeek != USE_LOCALE) {
488             return mFirstDayOfWeek;
489         }
490         return mCurrentDate.getFirstDayOfWeek();
491     }
492 
493     @Override
setEnabled(boolean enabled)494     public void setEnabled(boolean enabled) {
495         mContainer.setEnabled(enabled);
496         mDayPickerView.setEnabled(enabled);
497         mYearPickerView.setEnabled(enabled);
498         mHeaderYear.setEnabled(enabled);
499         mHeaderMonthDay.setEnabled(enabled);
500     }
501 
502     @Override
isEnabled()503     public boolean isEnabled() {
504         return mContainer.isEnabled();
505     }
506 
507     @Override
getCalendarView()508     public CalendarView getCalendarView() {
509         throw new UnsupportedOperationException("Not supported by calendar-mode DatePicker");
510     }
511 
512     @Override
setCalendarViewShown(boolean shown)513     public void setCalendarViewShown(boolean shown) {
514         // No-op for compatibility with the old DatePicker.
515     }
516 
517     @Override
getCalendarViewShown()518     public boolean getCalendarViewShown() {
519         return false;
520     }
521 
522     @Override
setSpinnersShown(boolean shown)523     public void setSpinnersShown(boolean shown) {
524         // No-op for compatibility with the old DatePicker.
525     }
526 
527     @Override
getSpinnersShown()528     public boolean getSpinnersShown() {
529         return false;
530     }
531 
532     @Override
onConfigurationChanged(Configuration newConfig)533     public void onConfigurationChanged(Configuration newConfig) {
534         setCurrentLocale(newConfig.locale);
535     }
536 
537     @Override
onSaveInstanceState(Parcelable superState)538     public Parcelable onSaveInstanceState(Parcelable superState) {
539         final int year = mCurrentDate.get(Calendar.YEAR);
540         final int month = mCurrentDate.get(Calendar.MONTH);
541         final int day = mCurrentDate.get(Calendar.DAY_OF_MONTH);
542 
543         int listPosition = -1;
544         int listPositionOffset = -1;
545 
546         if (mCurrentView == VIEW_MONTH_DAY) {
547             listPosition = mDayPickerView.getMostVisiblePosition();
548         } else if (mCurrentView == VIEW_YEAR) {
549             listPosition = mYearPickerView.getFirstVisiblePosition();
550             listPositionOffset = mYearPickerView.getFirstPositionOffset();
551         }
552 
553         return new SavedState(superState, year, month, day, mMinDate.getTimeInMillis(),
554                 mMaxDate.getTimeInMillis(), mCurrentView, listPosition, listPositionOffset);
555     }
556 
557     @Override
onRestoreInstanceState(Parcelable state)558     public void onRestoreInstanceState(Parcelable state) {
559         if (state instanceof SavedState) {
560             final SavedState ss = (SavedState) state;
561 
562             // TODO: Move instance state into DayPickerView, YearPickerView.
563             mCurrentDate.set(ss.getSelectedYear(), ss.getSelectedMonth(), ss.getSelectedDay());
564             mMinDate.setTimeInMillis(ss.getMinDate());
565             mMaxDate.setTimeInMillis(ss.getMaxDate());
566 
567             onCurrentDateChanged(false);
568 
569             final int currentView = ss.getCurrentView();
570             setCurrentView(currentView);
571 
572             final int listPosition = ss.getListPosition();
573             if (listPosition != -1) {
574                 if (currentView == VIEW_MONTH_DAY) {
575                     mDayPickerView.setPosition(listPosition);
576                 } else if (currentView == VIEW_YEAR) {
577                     final int listPositionOffset = ss.getListPositionOffset();
578                     mYearPickerView.setSelectionFromTop(listPosition, listPositionOffset);
579                 }
580             }
581         }
582     }
583 
584     @Override
dispatchPopulateAccessibilityEvent(AccessibilityEvent event)585     public boolean dispatchPopulateAccessibilityEvent(AccessibilityEvent event) {
586         onPopulateAccessibilityEvent(event);
587         return true;
588     }
589 
getAccessibilityClassName()590     public CharSequence getAccessibilityClassName() {
591         return DatePicker.class.getName();
592     }
593 
getDaysInMonth(int month, int year)594     private static int getDaysInMonth(int month, int year) {
595         switch (month) {
596             case Calendar.JANUARY:
597             case Calendar.MARCH:
598             case Calendar.MAY:
599             case Calendar.JULY:
600             case Calendar.AUGUST:
601             case Calendar.OCTOBER:
602             case Calendar.DECEMBER:
603                 return 31;
604             case Calendar.APRIL:
605             case Calendar.JUNE:
606             case Calendar.SEPTEMBER:
607             case Calendar.NOVEMBER:
608                 return 30;
609             case Calendar.FEBRUARY:
610                 return (year % 4 == 0 && (year % 100 != 0 || year % 400 == 0)) ? 29 : 28;
611             default:
612                 throw new IllegalArgumentException("Invalid Month");
613         }
614     }
615 
tryVibrate()616     private void tryVibrate() {
617         mDelegator.performHapticFeedback(HapticFeedbackConstants.CALENDAR_DATE);
618     }
619 }
620