1 /*
2  * Based on the UCB version of strftime.c with the copyright notice appearing below.
3  */
4 
5 /*
6 ** Copyright (c) 1989 The Regents of the University of California.
7 ** All rights reserved.
8 **
9 ** Redistribution and use in source and binary forms are permitted
10 ** provided that the above copyright notice and this paragraph are
11 ** duplicated in all such forms and that any documentation,
12 ** advertising materials, and other materials related to such
13 ** distribution and use acknowledge that the software was developed
14 ** by the University of California, Berkeley. The name of the
15 ** University may not be used to endorse or promote products derived
16 ** from this software without specific prior written permission.
17 ** THIS SOFTWARE IS PROVIDED "AS IS" AND WITHOUT ANY EXPRESS OR
18 ** IMPLIED WARRANTIES, INCLUDING, WITHOUT LIMITATION, THE IMPLIED
19 ** WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE.
20 */
21 package android.text.format;
22 
23 import android.content.res.Resources;
24 import android.icu.text.DateFormatSymbols;
25 import android.icu.text.DecimalFormatSymbols;
26 
27 import com.android.i18n.timezone.ZoneInfoData;
28 
29 import java.nio.CharBuffer;
30 import java.time.Instant;
31 import java.time.LocalDateTime;
32 import java.time.ZoneId;
33 import java.util.Formatter;
34 import java.util.Locale;
35 import java.util.TimeZone;
36 
37 /**
38  * Formatting logic for {@link Time}. Contains a port of Bionic's broken strftime_tz to Java.
39  *
40  * <p>This class is not thread safe.
41  */
42 class TimeFormatter {
43     // An arbitrary value outside the range representable by a char.
44     private static final int FORCE_LOWER_CASE = -1;
45 
46     private static final int SECSPERMIN = 60;
47     private static final int MINSPERHOUR = 60;
48     private static final int DAYSPERWEEK = 7;
49     private static final int MONSPERYEAR = 12;
50     private static final int HOURSPERDAY = 24;
51     private static final int DAYSPERLYEAR = 366;
52     private static final int DAYSPERNYEAR = 365;
53 
54     /**
55      * The Locale for which the cached symbols and formats have been loaded.
56      */
57     private static Locale sLocale;
58     private static DateFormatSymbols sDateFormatSymbols;
59     private static DecimalFormatSymbols sDecimalFormatSymbols;
60     private static String sTimeOnlyFormat;
61     private static String sDateOnlyFormat;
62     private static String sDateTimeFormat;
63 
64     private final DateFormatSymbols dateFormatSymbols;
65     private final DecimalFormatSymbols decimalFormatSymbols;
66     private final String dateTimeFormat;
67     private final String timeOnlyFormat;
68     private final String dateOnlyFormat;
69 
70     private StringBuilder outputBuilder;
71     private Formatter numberFormatter;
72 
TimeFormatter()73     public TimeFormatter() {
74         synchronized (TimeFormatter.class) {
75             Locale locale = Locale.getDefault();
76 
77             if (sLocale == null || !(locale.equals(sLocale))) {
78                 sLocale = locale;
79                 sDateFormatSymbols = DateFormat.getIcuDateFormatSymbols(locale);
80                 sDecimalFormatSymbols = DecimalFormatSymbols.getInstance(locale);
81 
82                 Resources r = Resources.getSystem();
83                 sTimeOnlyFormat = r.getString(com.android.internal.R.string.time_of_day);
84                 sDateOnlyFormat = r.getString(com.android.internal.R.string.month_day_year);
85                 sDateTimeFormat = r.getString(com.android.internal.R.string.date_and_time);
86             }
87 
88             this.dateFormatSymbols = sDateFormatSymbols;
89             this.decimalFormatSymbols = sDecimalFormatSymbols;
90             this.dateTimeFormat = sDateTimeFormat;
91             this.timeOnlyFormat = sTimeOnlyFormat;
92             this.dateOnlyFormat = sDateOnlyFormat;
93         }
94     }
95 
96     /**
97      * The implementation of {@link TimeMigrationUtils#formatMillisWithFixedFormat(long)} for
98      * 2038-safe formatting with the pattern "%Y-%m-%d %H:%M:%S" and including the historic
99      * incorrect digit localization behavior.
100      */
formatMillisWithFixedFormat(long timeMillis)101     String formatMillisWithFixedFormat(long timeMillis) {
102         // This method is deliberately not a general purpose replacement for format(String,
103         // ZoneInfoData.WallTime, ZoneInfoData): It hard-codes the pattern used; many of the
104         // pattern characters supported by Time.format() have unusual behavior which would make
105         // using java.time.format or similar packages difficult. It would be a lot of work to share
106         // behavior and many internal Android usecases can be covered by this common pattern
107         // behavior.
108 
109         // No need to worry about overflow / underflow: long millis is representable by Instant and
110         // LocalDateTime with room to spare.
111         Instant instant = Instant.ofEpochMilli(timeMillis);
112 
113         // Date/times are calculated in the current system default time zone.
114         LocalDateTime localDateTime = LocalDateTime.ofInstant(instant, ZoneId.systemDefault());
115 
116         // You'd think it would be as simple as:
117         // DateTimeFormatter formatter = DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss", locale);
118         // return formatter.format(localDateTime);
119         // but we retain Time's behavior around digits.
120 
121         StringBuilder stringBuilder = new StringBuilder(19);
122 
123         // This effectively uses the US locale because number localization is handled separately
124         // (see below).
125         stringBuilder.append(localDateTime.getYear());
126         stringBuilder.append('-');
127         append2DigitNumber(stringBuilder, localDateTime.getMonthValue());
128         stringBuilder.append('-');
129         append2DigitNumber(stringBuilder, localDateTime.getDayOfMonth());
130         stringBuilder.append(' ');
131         append2DigitNumber(stringBuilder, localDateTime.getHour());
132         stringBuilder.append(':');
133         append2DigitNumber(stringBuilder, localDateTime.getMinute());
134         stringBuilder.append(':');
135         append2DigitNumber(stringBuilder, localDateTime.getSecond());
136 
137         String result = stringBuilder.toString();
138         return localizeDigits(result);
139     }
140 
141     /** Zero-pads value as needed to achieve a 2-digit number. */
append2DigitNumber(StringBuilder builder, int value)142     private static void append2DigitNumber(StringBuilder builder, int value) {
143         if (value < 10) {
144             builder.append('0');
145         }
146         builder.append(value);
147     }
148 
149     /**
150      * Format the specified {@code wallTime} using {@code pattern}. The output is returned.
151      */
format(String pattern, ZoneInfoData.WallTime wallTime, ZoneInfoData zoneInfoData)152     public String format(String pattern, ZoneInfoData.WallTime wallTime,
153             ZoneInfoData zoneInfoData) {
154         try {
155             StringBuilder stringBuilder = new StringBuilder();
156 
157             outputBuilder = stringBuilder;
158             // This uses the US locale because number localization is handled separately (see below)
159             // and locale sensitive strings are output directly using outputBuilder.
160             numberFormatter = new Formatter(stringBuilder, Locale.US);
161 
162             formatInternal(pattern, wallTime, zoneInfoData);
163             String result = stringBuilder.toString();
164             // The localizeDigits() behavior is the source of a bug since some formats are defined
165             // as being in ASCII and not localized.
166             return localizeDigits(result);
167         } finally {
168             outputBuilder = null;
169             numberFormatter = null;
170         }
171     }
172 
localizeDigits(String s)173     private String localizeDigits(String s) {
174         if (decimalFormatSymbols.getZeroDigit() == '0') {
175             return s;
176         }
177 
178         int length = s.length();
179         int offsetToLocalizedDigits = decimalFormatSymbols.getZeroDigit() - '0';
180         StringBuilder result = new StringBuilder(length);
181         for (int i = 0; i < length; ++i) {
182             char ch = s.charAt(i);
183             if (ch >= '0' && ch <= '9') {
184                 ch += offsetToLocalizedDigits;
185             }
186             result.append(ch);
187         }
188         return result.toString();
189     }
190 
191     /**
192      * Format the specified {@code wallTime} using {@code pattern}. The output is written to
193      * {@link #outputBuilder}.
194      */
formatInternal(String pattern, ZoneInfoData.WallTime wallTime, ZoneInfoData zoneInfoData)195     private void formatInternal(String pattern, ZoneInfoData.WallTime wallTime,
196             ZoneInfoData zoneInfoData) {
197         CharBuffer formatBuffer = CharBuffer.wrap(pattern);
198         while (formatBuffer.remaining() > 0) {
199             boolean outputCurrentChar = true;
200             char currentChar = formatBuffer.get(formatBuffer.position());
201             if (currentChar == '%') {
202                 outputCurrentChar = handleToken(formatBuffer, wallTime, zoneInfoData);
203             }
204             if (outputCurrentChar) {
205                 outputBuilder.append(formatBuffer.get(formatBuffer.position()));
206             }
207             formatBuffer.position(formatBuffer.position() + 1);
208         }
209     }
210 
handleToken(CharBuffer formatBuffer, ZoneInfoData.WallTime wallTime, ZoneInfoData zoneInfoData)211     private boolean handleToken(CharBuffer formatBuffer, ZoneInfoData.WallTime wallTime,
212             ZoneInfoData zoneInfoData) {
213 
214         // The char at formatBuffer.position() is expected to be '%' at this point.
215         int modifier = 0;
216         while (formatBuffer.remaining() > 1) {
217             // Increment the position then get the new current char.
218             formatBuffer.position(formatBuffer.position() + 1);
219             char currentChar = formatBuffer.get(formatBuffer.position());
220             switch (currentChar) {
221                 case 'A':
222                     modifyAndAppend(
223                         (wallTime.getWeekDay() < 0 || wallTime.getWeekDay() >= DAYSPERWEEK)
224                             ? "?"
225                             : dateFormatSymbols.getWeekdays(DateFormatSymbols.FORMAT,
226                                 DateFormatSymbols.WIDE)[wallTime.getWeekDay() + 1],
227                             modifier);
228                     return false;
229                 case 'a':
230                     modifyAndAppend(
231                         (wallTime.getWeekDay() < 0 || wallTime.getWeekDay() >= DAYSPERWEEK)
232                             ? "?"
233                             : dateFormatSymbols.getWeekdays(DateFormatSymbols.FORMAT,
234                                 DateFormatSymbols.ABBREVIATED)[wallTime.getWeekDay() + 1],
235                             modifier);
236                     return false;
237                 case 'B':
238                     if (modifier == '-') {
239                         modifyAndAppend(
240                             (wallTime.getMonth() < 0 || wallTime.getMonth() >= MONSPERYEAR)
241                                 ? "?"
242                                 : dateFormatSymbols.getMonths(DateFormatSymbols.STANDALONE,
243                                     DateFormatSymbols.WIDE)[wallTime.getMonth()],
244                                 modifier);
245                     } else {
246                         modifyAndAppend(
247                             (wallTime.getMonth() < 0 || wallTime.getMonth() >= MONSPERYEAR)
248                                 ? "?"
249                                 : dateFormatSymbols.getMonths(DateFormatSymbols.FORMAT,
250                                     DateFormatSymbols.WIDE)[wallTime.getMonth()],
251                                 modifier);
252                     }
253                     return false;
254                 case 'b':
255                 case 'h':
256                     modifyAndAppend((wallTime.getMonth() < 0 || wallTime.getMonth() >= MONSPERYEAR)
257                             ? "?"
258                             : dateFormatSymbols.getMonths(DateFormatSymbols.FORMAT,
259                                 DateFormatSymbols.ABBREVIATED)[wallTime.getMonth()],
260                             modifier);
261                     return false;
262                 case 'C':
263                     outputYear(wallTime.getYear(), true, false, modifier);
264                     return false;
265                 case 'c':
266                     formatInternal(dateTimeFormat, wallTime, zoneInfoData);
267                     return false;
268                 case 'D':
269                     formatInternal("%m/%d/%y", wallTime, zoneInfoData);
270                     return false;
271                 case 'd':
272                     numberFormatter.format(getFormat(modifier, "%02d", "%2d", "%d", "%02d"),
273                             wallTime.getMonthDay());
274                     return false;
275                 case 'E':
276                 case 'O':
277                     // C99 locale modifiers are not supported.
278                     continue;
279                 case '_':
280                 case '-':
281                 case '0':
282                 case '^':
283                 case '#':
284                     modifier = currentChar;
285                     continue;
286                 case 'e':
287                     numberFormatter.format(getFormat(modifier, "%2d", "%2d", "%d", "%02d"),
288                             wallTime.getMonthDay());
289                     return false;
290                 case 'F':
291                     formatInternal("%Y-%m-%d", wallTime, zoneInfoData);
292                     return false;
293                 case 'H':
294                     numberFormatter.format(getFormat(modifier, "%02d", "%2d", "%d", "%02d"),
295                             wallTime.getHour());
296                     return false;
297                 case 'I':
298                     int hour = (wallTime.getHour() % 12 != 0) ? (wallTime.getHour() % 12) : 12;
299                     numberFormatter.format(getFormat(modifier, "%02d", "%2d", "%d", "%02d"), hour);
300                     return false;
301                 case 'j':
302                     int yearDay = wallTime.getYearDay() + 1;
303                     numberFormatter.format(getFormat(modifier, "%03d", "%3d", "%d", "%03d"),
304                             yearDay);
305                     return false;
306                 case 'k':
307                     numberFormatter.format(getFormat(modifier, "%2d", "%2d", "%d", "%02d"),
308                             wallTime.getHour());
309                     return false;
310                 case 'l':
311                     int n2 = (wallTime.getHour() % 12 != 0) ? (wallTime.getHour() % 12) : 12;
312                     numberFormatter.format(getFormat(modifier, "%2d", "%2d", "%d", "%02d"), n2);
313                     return false;
314                 case 'M':
315                     numberFormatter.format(getFormat(modifier, "%02d", "%2d", "%d", "%02d"),
316                             wallTime.getMinute());
317                     return false;
318                 case 'm':
319                     numberFormatter.format(getFormat(modifier, "%02d", "%2d", "%d", "%02d"),
320                             wallTime.getMonth() + 1);
321                     return false;
322                 case 'n':
323                     outputBuilder.append('\n');
324                     return false;
325                 case 'p':
326                     modifyAndAppend((wallTime.getHour() >= (HOURSPERDAY / 2))
327                             ? dateFormatSymbols.getAmPmStrings()[1]
328                             : dateFormatSymbols.getAmPmStrings()[0], modifier);
329                     return false;
330                 case 'P':
331                     modifyAndAppend((wallTime.getHour() >= (HOURSPERDAY / 2))
332                             ? dateFormatSymbols.getAmPmStrings()[1]
333                             : dateFormatSymbols.getAmPmStrings()[0], FORCE_LOWER_CASE);
334                     return false;
335                 case 'R':
336                     formatInternal("%H:%M", wallTime, zoneInfoData);
337                     return false;
338                 case 'r':
339                     formatInternal("%I:%M:%S %p", wallTime, zoneInfoData);
340                     return false;
341                 case 'S':
342                     numberFormatter.format(getFormat(modifier, "%02d", "%2d", "%d", "%02d"),
343                             wallTime.getSecond());
344                     return false;
345                 case 's':
346                     int timeInSeconds = wallTime.mktime(zoneInfoData);
347                     outputBuilder.append(Integer.toString(timeInSeconds));
348                     return false;
349                 case 'T':
350                     formatInternal("%H:%M:%S", wallTime, zoneInfoData);
351                     return false;
352                 case 't':
353                     outputBuilder.append('\t');
354                     return false;
355                 case 'U':
356                     numberFormatter.format(getFormat(modifier, "%02d", "%2d", "%d", "%02d"),
357                             (wallTime.getYearDay() + DAYSPERWEEK - wallTime.getWeekDay())
358                                     / DAYSPERWEEK);
359                     return false;
360                 case 'u':
361                     int day = (wallTime.getWeekDay() == 0) ? DAYSPERWEEK : wallTime.getWeekDay();
362                     numberFormatter.format("%d", day);
363                     return false;
364                 case 'V':   /* ISO 8601 week number */
365                 case 'G':   /* ISO 8601 year (four digits) */
366                 case 'g':   /* ISO 8601 year (two digits) */
367                 {
368                     int year = wallTime.getYear();
369                     int yday = wallTime.getYearDay();
370                     int wday = wallTime.getWeekDay();
371                     int w;
372                     while (true) {
373                         int len = isLeap(year) ? DAYSPERLYEAR : DAYSPERNYEAR;
374                         // What yday (-3 ... 3) does the ISO year begin on?
375                         int bot = ((yday + 11 - wday) % DAYSPERWEEK) - 3;
376                         // What yday does the NEXT ISO year begin on?
377                         int top = bot - (len % DAYSPERWEEK);
378                         if (top < -3) {
379                             top += DAYSPERWEEK;
380                         }
381                         top += len;
382                         if (yday >= top) {
383                             ++year;
384                             w = 1;
385                             break;
386                         }
387                         if (yday >= bot) {
388                             w = 1 + ((yday - bot) / DAYSPERWEEK);
389                             break;
390                         }
391                         --year;
392                         yday += isLeap(year) ? DAYSPERLYEAR : DAYSPERNYEAR;
393                     }
394                     if (currentChar == 'V') {
395                         numberFormatter.format(getFormat(modifier, "%02d", "%2d", "%d", "%02d"), w);
396                     } else if (currentChar == 'g') {
397                         outputYear(year, false, true, modifier);
398                     } else {
399                         outputYear(year, true, true, modifier);
400                     }
401                     return false;
402                 }
403                 case 'v':
404                     formatInternal("%e-%b-%Y", wallTime, zoneInfoData);
405                     return false;
406                 case 'W':
407                     int n = (wallTime.getYearDay() + DAYSPERWEEK - (
408                                     wallTime.getWeekDay() != 0 ? (wallTime.getWeekDay() - 1)
409                                             : (DAYSPERWEEK - 1))) / DAYSPERWEEK;
410                     numberFormatter.format(getFormat(modifier, "%02d", "%2d", "%d", "%02d"), n);
411                     return false;
412                 case 'w':
413                     numberFormatter.format("%d", wallTime.getWeekDay());
414                     return false;
415                 case 'X':
416                     formatInternal(timeOnlyFormat, wallTime, zoneInfoData);
417                     return false;
418                 case 'x':
419                     formatInternal(dateOnlyFormat, wallTime, zoneInfoData);
420                     return false;
421                 case 'y':
422                     outputYear(wallTime.getYear(), false, true, modifier);
423                     return false;
424                 case 'Y':
425                     outputYear(wallTime.getYear(), true, true, modifier);
426                     return false;
427                 case 'Z':
428                     if (wallTime.getIsDst() < 0) {
429                         return false;
430                     }
431                     boolean isDst = wallTime.getIsDst() != 0;
432                     modifyAndAppend(TimeZone.getTimeZone(zoneInfoData.getID())
433                             .getDisplayName(isDst, TimeZone.SHORT), modifier);
434                     return false;
435                 case 'z': {
436                     if (wallTime.getIsDst() < 0) {
437                         return false;
438                     }
439                     int diff = wallTime.getGmtOffset();
440                     char sign;
441                     if (diff < 0) {
442                         sign = '-';
443                         diff = -diff;
444                     } else {
445                         sign = '+';
446                     }
447                     outputBuilder.append(sign);
448                     diff /= SECSPERMIN;
449                     diff = (diff / MINSPERHOUR) * 100 + (diff % MINSPERHOUR);
450                     numberFormatter.format(getFormat(modifier, "%04d", "%4d", "%d", "%04d"), diff);
451                     return false;
452                 }
453                 case '+':
454                     formatInternal("%a %b %e %H:%M:%S %Z %Y", wallTime, zoneInfoData);
455                     return false;
456                 case '%':
457                     // If conversion char is undefined, behavior is undefined. Print out the
458                     // character itself.
459                 default:
460                     return true;
461             }
462         }
463         return true;
464     }
465 
modifyAndAppend(CharSequence str, int modifier)466     private void modifyAndAppend(CharSequence str, int modifier) {
467         switch (modifier) {
468             case FORCE_LOWER_CASE:
469                 for (int i = 0; i < str.length(); i++) {
470                     outputBuilder.append(brokenToLower(str.charAt(i)));
471                 }
472                 break;
473             case '^':
474                 for (int i = 0; i < str.length(); i++) {
475                     outputBuilder.append(brokenToUpper(str.charAt(i)));
476                 }
477                 break;
478             case '#':
479                 for (int i = 0; i < str.length(); i++) {
480                     char c = str.charAt(i);
481                     if (brokenIsUpper(c)) {
482                         c = brokenToLower(c);
483                     } else if (brokenIsLower(c)) {
484                         c = brokenToUpper(c);
485                     }
486                     outputBuilder.append(c);
487                 }
488                 break;
489             default:
490                 outputBuilder.append(str);
491         }
492     }
493 
outputYear(int value, boolean outputTop, boolean outputBottom, int modifier)494     private void outputYear(int value, boolean outputTop, boolean outputBottom, int modifier) {
495         int lead;
496         int trail;
497 
498         final int DIVISOR = 100;
499         trail = value % DIVISOR;
500         lead = value / DIVISOR + trail / DIVISOR;
501         trail %= DIVISOR;
502         if (trail < 0 && lead > 0) {
503             trail += DIVISOR;
504             --lead;
505         } else if (lead < 0 && trail > 0) {
506             trail -= DIVISOR;
507             ++lead;
508         }
509         if (outputTop) {
510             if (lead == 0 && trail < 0) {
511                 outputBuilder.append("-0");
512             } else {
513                 numberFormatter.format(getFormat(modifier, "%02d", "%2d", "%d", "%02d"), lead);
514             }
515         }
516         if (outputBottom) {
517             int n = ((trail < 0) ? -trail : trail);
518             numberFormatter.format(getFormat(modifier, "%02d", "%2d", "%d", "%02d"), n);
519         }
520     }
521 
getFormat(int modifier, String normal, String underscore, String dash, String zero)522     private static String getFormat(int modifier, String normal, String underscore, String dash,
523             String zero) {
524         switch (modifier) {
525             case '_':
526                 return underscore;
527             case '-':
528                 return dash;
529             case '0':
530                 return zero;
531         }
532         return normal;
533     }
534 
isLeap(int year)535     private static boolean isLeap(int year) {
536         return (((year) % 4) == 0 && (((year) % 100) != 0 || ((year) % 400) == 0));
537     }
538 
539     /**
540      * A broken implementation of {@link Character#isUpperCase(char)} that assumes ASCII codes in
541      * order to be compatible with the old native implementation.
542      */
brokenIsUpper(char toCheck)543     private static boolean brokenIsUpper(char toCheck) {
544         return toCheck >= 'A' && toCheck <= 'Z';
545     }
546 
547     /**
548      * A broken implementation of {@link Character#isLowerCase(char)} that assumes ASCII codes in
549      * order to be compatible with the old native implementation.
550      */
brokenIsLower(char toCheck)551     private static boolean brokenIsLower(char toCheck) {
552         return toCheck >= 'a' && toCheck <= 'z';
553     }
554 
555     /**
556      * A broken implementation of {@link Character#toLowerCase(char)} that assumes ASCII codes in
557      * order to be compatible with the old native implementation.
558      */
brokenToLower(char input)559     private static char brokenToLower(char input) {
560         if (input >= 'A' && input <= 'Z') {
561             return (char) (input - 'A' + 'a');
562         }
563         return input;
564     }
565 
566     /**
567      * A broken implementation of {@link Character#toUpperCase(char)} that assumes ASCII codes in
568      * order to be compatible with the old native implementation.
569      */
brokenToUpper(char input)570     private static char brokenToUpper(char input) {
571         if (input >= 'a' && input <= 'z') {
572             return (char) (input - 'a' + 'A');
573         }
574         return input;
575     }
576 
577 }
578