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