• Home
  • Line#
  • Scopes#
  • Navigate#
  • Raw
  • Download
1 /*
2  * Copyright (C) 2015 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.text.format;
18 
19 import static android.text.format.DateUtils.FORMAT_ABBREV_ALL;
20 import static android.text.format.DateUtils.FORMAT_ABBREV_MONTH;
21 import static android.text.format.DateUtils.FORMAT_ABBREV_RELATIVE;
22 import static android.text.format.DateUtils.FORMAT_NO_YEAR;
23 import static android.text.format.DateUtils.FORMAT_NUMERIC_DATE;
24 import static android.text.format.DateUtils.FORMAT_SHOW_DATE;
25 import static android.text.format.DateUtils.FORMAT_SHOW_TIME;
26 import static android.text.format.DateUtils.FORMAT_SHOW_YEAR;
27 
28 import static com.android.internal.annotations.VisibleForTesting.Visibility.PACKAGE;
29 
30 import android.icu.text.DisplayContext;
31 import android.icu.util.Calendar;
32 import android.icu.util.ULocale;
33 import android.util.LruCache;
34 
35 import com.android.internal.annotations.VisibleForTesting;
36 
37 import java.util.Locale;
38 
39 /**
40  * Exposes icu4j's RelativeDateTimeFormatter.
41  *
42  * @hide
43  */
44 @VisibleForTesting(visibility = PACKAGE)
45 @android.ravenwood.annotation.RavenwoodKeepWholeClass
46 public final class RelativeDateTimeFormatter {
47 
48     public static final long SECOND_IN_MILLIS = 1000;
49     public static final long MINUTE_IN_MILLIS = SECOND_IN_MILLIS * 60;
50     public static final long HOUR_IN_MILLIS = MINUTE_IN_MILLIS * 60;
51     public static final long DAY_IN_MILLIS = HOUR_IN_MILLIS * 24;
52     public static final long WEEK_IN_MILLIS = DAY_IN_MILLIS * 7;
53     // YEAR_IN_MILLIS considers 364 days as a year. However, since this
54     // constant comes from public API in DateUtils, it cannot be fixed here.
55     public static final long YEAR_IN_MILLIS = WEEK_IN_MILLIS * 52;
56 
57     private static final int DAY_IN_MS = 24 * 60 * 60 * 1000;
58     private static final int EPOCH_JULIAN_DAY = 2440588;
59 
60     private static final FormatterCache CACHED_FORMATTERS = new FormatterCache();
61 
62     static class FormatterCache
63             extends LruCache<String, android.icu.text.RelativeDateTimeFormatter> {
FormatterCache()64         FormatterCache() {
65             super(8);
66         }
67     }
68 
RelativeDateTimeFormatter()69     private RelativeDateTimeFormatter() {
70     }
71 
72     /**
73      * This is the internal API that implements the functionality of DateUtils
74      * .getRelativeTimeSpanString(long,
75      * long, long, int), which is to return a string describing 'time' as a time relative to 'now'
76      * such as '5 minutes ago', or 'In 2 days'. More examples can be found in DateUtils' doc.
77      * <p>
78      * In the implementation below, it selects the appropriate time unit based on the elapsed time
79      * between time' and 'now', e.g. minutes, days and etc. Callers may also specify the desired
80      * minimum resolution to show in the result. For example, '45 minutes ago' will become '0 hours
81      * ago' when minResolution is HOUR_IN_MILLIS. Once getting the quantity and unit to display, it
82      * calls icu4j's RelativeDateTimeFormatter to format the actual string according to the given
83      * locale.
84      * <p>
85      * Note that when minResolution is set to DAY_IN_MILLIS, it returns the result depending on the
86      * actual date difference. For example, it will return 'Yesterday' even if 'time' was less than
87      * 24 hours ago but falling onto a different calendar day.
88      * <p>
89      * It takes two additional parameters of Locale and TimeZone than the DateUtils' API. Caller
90      * must specify the locale and timezone. FORMAT_ABBREV_RELATIVE or FORMAT_ABBREV_ALL can be set
91      * in 'flags' to get the abbreviated forms when available. When 'time' equals to 'now', it
92      * always // returns a string like '0 seconds/minutes/... ago' according to minResolution.
93      */
getRelativeTimeSpanString(Locale locale, java.util.TimeZone tz, long time, long now, long minResolution, int flags)94     public static String getRelativeTimeSpanString(Locale locale, java.util.TimeZone tz, long time,
95             long now, long minResolution, int flags) {
96         // Android has been inconsistent about capitalization in the past. e.g. bug
97         // http://b/20247811.
98         // Now we capitalize everything consistently.
99         final DisplayContext displayContext =
100                 DisplayContext.CAPITALIZATION_FOR_BEGINNING_OF_SENTENCE;
101         return getRelativeTimeSpanString(locale, tz, time, now, minResolution, flags,
102                 displayContext);
103     }
104 
getRelativeTimeSpanString(Locale locale, java.util.TimeZone tz, long time, long now, long minResolution, int flags, DisplayContext displayContext)105     public static String getRelativeTimeSpanString(Locale locale, java.util.TimeZone tz, long time,
106             long now, long minResolution, int flags, DisplayContext displayContext) {
107         if (locale == null) {
108             throw new NullPointerException("locale == null");
109         }
110         if (tz == null) {
111             throw new NullPointerException("tz == null");
112         }
113         ULocale icuLocale = ULocale.forLocale(locale);
114         android.icu.util.TimeZone icuTimeZone = DateUtilsBridge.icuTimeZone(tz);
115         return getRelativeTimeSpanString(icuLocale, icuTimeZone, time, now, minResolution, flags,
116                 displayContext);
117     }
118 
getRelativeTimeSpanString(ULocale icuLocale, android.icu.util.TimeZone icuTimeZone, long time, long now, long minResolution, int flags, DisplayContext displayContext)119     private static String getRelativeTimeSpanString(ULocale icuLocale,
120             android.icu.util.TimeZone icuTimeZone, long time, long now, long minResolution,
121             int flags,
122             DisplayContext displayContext) {
123 
124         long duration = Math.abs(now - time);
125         boolean past = (now >= time);
126 
127         android.icu.text.RelativeDateTimeFormatter.Style style;
128         if ((flags & (FORMAT_ABBREV_RELATIVE | FORMAT_ABBREV_ALL)) != 0) {
129             style = android.icu.text.RelativeDateTimeFormatter.Style.SHORT;
130         } else {
131             style = android.icu.text.RelativeDateTimeFormatter.Style.LONG;
132         }
133 
134         android.icu.text.RelativeDateTimeFormatter.Direction direction;
135         if (past) {
136             direction = android.icu.text.RelativeDateTimeFormatter.Direction.LAST;
137         } else {
138             direction = android.icu.text.RelativeDateTimeFormatter.Direction.NEXT;
139         }
140 
141         // 'relative' defaults to true as we are generating relative time span
142         // string. It will be set to false when we try to display strings without
143         // a quantity, such as 'Yesterday', etc.
144         boolean relative = true;
145         int count;
146         android.icu.text.RelativeDateTimeFormatter.RelativeUnit unit;
147         android.icu.text.RelativeDateTimeFormatter.AbsoluteUnit aunit = null;
148 
149         if (duration < MINUTE_IN_MILLIS && minResolution < MINUTE_IN_MILLIS) {
150             count = (int) (duration / SECOND_IN_MILLIS);
151             unit = android.icu.text.RelativeDateTimeFormatter.RelativeUnit.SECONDS;
152         } else if (duration < HOUR_IN_MILLIS && minResolution < HOUR_IN_MILLIS) {
153             count = (int) (duration / MINUTE_IN_MILLIS);
154             unit = android.icu.text.RelativeDateTimeFormatter.RelativeUnit.MINUTES;
155         } else if (duration < DAY_IN_MILLIS && minResolution < DAY_IN_MILLIS) {
156             // Even if 'time' actually happened yesterday, we don't format it as
157             // "Yesterday" in this case. Unless the duration is longer than a day,
158             // or minResolution is specified as DAY_IN_MILLIS by user.
159             count = (int) (duration / HOUR_IN_MILLIS);
160             unit = android.icu.text.RelativeDateTimeFormatter.RelativeUnit.HOURS;
161         } else if (duration < WEEK_IN_MILLIS && minResolution < WEEK_IN_MILLIS) {
162             count = Math.abs(dayDistance(icuTimeZone, time, now));
163             unit = android.icu.text.RelativeDateTimeFormatter.RelativeUnit.DAYS;
164 
165             if (count == 2) {
166                 // Some locales have special terms for "2 days ago". Return them if
167                 // available. Note that we cannot set up direction and unit here and
168                 // make it fall through to use the call near the end of the function,
169                 // because for locales that don't have special terms for "2 days ago",
170                 // icu4j returns an empty string instead of falling back to strings
171                 // like "2 days ago".
172                 String str;
173                 if (past) {
174                     synchronized (CACHED_FORMATTERS) {
175                         str = getFormatter(icuLocale, style, displayContext).format(
176                                 android.icu.text.RelativeDateTimeFormatter.Direction.LAST_2,
177                                 android.icu.text.RelativeDateTimeFormatter.AbsoluteUnit.DAY);
178                     }
179                 } else {
180                     synchronized (CACHED_FORMATTERS) {
181                         str = getFormatter(icuLocale, style, displayContext).format(
182                                 android.icu.text.RelativeDateTimeFormatter.Direction.NEXT_2,
183                                 android.icu.text.RelativeDateTimeFormatter.AbsoluteUnit.DAY);
184                     }
185                 }
186                 if (str != null && !str.isEmpty()) {
187                     return str;
188                 }
189                 // Fall back to show something like "2 days ago".
190             } else if (count == 1) {
191                 // Show "Yesterday / Tomorrow" instead of "1 day ago / In 1 day".
192                 aunit = android.icu.text.RelativeDateTimeFormatter.AbsoluteUnit.DAY;
193                 relative = false;
194             } else if (count == 0) {
195                 // Show "Today" if time and now are on the same day.
196                 aunit = android.icu.text.RelativeDateTimeFormatter.AbsoluteUnit.DAY;
197                 direction = android.icu.text.RelativeDateTimeFormatter.Direction.THIS;
198                 relative = false;
199             }
200         } else if (minResolution == WEEK_IN_MILLIS) {
201             count = (int) (duration / WEEK_IN_MILLIS);
202             unit = android.icu.text.RelativeDateTimeFormatter.RelativeUnit.WEEKS;
203         } else {
204             Calendar timeCalendar = DateUtilsBridge.createIcuCalendar(icuTimeZone, icuLocale, time);
205             // The duration is longer than a week and minResolution is not
206             // WEEK_IN_MILLIS. Return the absolute date instead of relative time.
207 
208             // Bug 19822016:
209             // If user doesn't supply the year display flag, we need to explicitly
210             // set that to show / hide the year based on time and now. Otherwise
211             // formatDateRange() would determine that based on the current system
212             // time and may give wrong results.
213             if ((flags & (FORMAT_NO_YEAR | FORMAT_SHOW_YEAR)) == 0) {
214                 Calendar nowCalendar = DateUtilsBridge.createIcuCalendar(icuTimeZone, icuLocale,
215                         now);
216 
217                 if (timeCalendar.get(Calendar.YEAR) != nowCalendar.get(Calendar.YEAR)) {
218                     flags |= FORMAT_SHOW_YEAR;
219                 } else {
220                     flags |= FORMAT_NO_YEAR;
221                 }
222             }
223             return DateTimeFormat.format(icuLocale, timeCalendar, flags, displayContext);
224         }
225 
226         synchronized (CACHED_FORMATTERS) {
227             android.icu.text.RelativeDateTimeFormatter formatter =
228                     getFormatter(icuLocale, style, displayContext);
229             if (relative) {
230                 return formatter.format(count, direction, unit);
231             } else {
232                 return formatter.format(direction, aunit);
233             }
234         }
235     }
236 
237     /**
238      * This is the internal API that implements DateUtils.getRelativeDateTimeString(long, long,
239      * long, long, int), which is to return a string describing 'time' as a time relative to 'now',
240      * formatted like '[relative time/date], [time]'. More examples can be found in DateUtils' doc.
241      * <p>
242      * The function is similar to getRelativeTimeSpanString, but it always appends the absolute time
243      * to the relative time string to return '[relative time/date clause], [absolute time clause]'.
244      * It also takes an extra parameter transitionResolution to determine the format of the date
245      * clause. When the elapsed time is less than the transition resolution, it displays the
246      * relative time string. Otherwise, it gives the absolute numeric date string as the date
247      * clause. With the date and time clauses, it relies on icu4j's
248      * RelativeDateTimeFormatter::combineDateAndTime()
249      * to concatenate the two.
250      * <p>
251      * It takes two additional parameters of Locale and TimeZone than the DateUtils' API. Caller
252      * must specify the locale and timezone. FORMAT_ABBREV_RELATIVE or FORMAT_ABBREV_ALL can be set
253      * in 'flags' to get the abbreviated forms when they are available.
254      * <p>
255      * Bug 5252772: Since the absolute time will always be part of the result, minResolution will be
256      * set to at least DAY_IN_MILLIS to correctly indicate the date difference. For example, when
257      * it's 1:30 AM, it will return 'Yesterday, 11:30 PM' for getRelativeDateTimeString(null, null,
258      * now - 2 hours, now, HOUR_IN_MILLIS, DAY_IN_MILLIS, 0), instead of '2 hours ago, 11:30 PM'
259      * even with minResolution being HOUR_IN_MILLIS.
260      */
getRelativeDateTimeString(Locale locale, java.util.TimeZone tz, long time, long now, long minResolution, long transitionResolution, int flags)261     public static String getRelativeDateTimeString(Locale locale, java.util.TimeZone tz, long time,
262             long now, long minResolution, long transitionResolution, int flags) {
263 
264         if (locale == null) {
265             throw new NullPointerException("locale == null");
266         }
267         if (tz == null) {
268             throw new NullPointerException("tz == null");
269         }
270         ULocale icuLocale = ULocale.forLocale(locale);
271         android.icu.util.TimeZone icuTimeZone = DateUtilsBridge.icuTimeZone(tz);
272 
273         long duration = Math.abs(now - time);
274         // It doesn't make much sense to have results like: "1 week ago, 10:50 AM".
275         if (transitionResolution > WEEK_IN_MILLIS) {
276             transitionResolution = WEEK_IN_MILLIS;
277         }
278         android.icu.text.RelativeDateTimeFormatter.Style style;
279         if ((flags & (FORMAT_ABBREV_RELATIVE | FORMAT_ABBREV_ALL)) != 0) {
280             style = android.icu.text.RelativeDateTimeFormatter.Style.SHORT;
281         } else {
282             style = android.icu.text.RelativeDateTimeFormatter.Style.LONG;
283         }
284 
285         Calendar timeCalendar = DateUtilsBridge.createIcuCalendar(icuTimeZone, icuLocale, time);
286         Calendar nowCalendar = DateUtilsBridge.createIcuCalendar(icuTimeZone, icuLocale, now);
287 
288         int days = Math.abs(DateUtilsBridge.dayDistance(timeCalendar, nowCalendar));
289 
290         // Now get the date clause, either in relative format or the actual date.
291         String dateClause;
292         if (duration < transitionResolution) {
293             // This is to fix bug 5252772. If there is any date difference, we should
294             // promote the minResolution to DAY_IN_MILLIS so that it can display the
295             // date instead of "x hours/minutes ago, [time]".
296             if (days > 0 && minResolution < DAY_IN_MILLIS) {
297                 minResolution = DAY_IN_MILLIS;
298             }
299             dateClause = getRelativeTimeSpanString(icuLocale, icuTimeZone, time, now, minResolution,
300                     flags, DisplayContext.CAPITALIZATION_FOR_BEGINNING_OF_SENTENCE);
301         } else {
302             // We always use fixed flags to format the date clause. User-supplied
303             // flags are ignored.
304             if (timeCalendar.get(Calendar.YEAR) != nowCalendar.get(Calendar.YEAR)) {
305                 // Different years
306                 flags = FORMAT_SHOW_DATE | FORMAT_SHOW_YEAR | FORMAT_NUMERIC_DATE;
307             } else {
308                 // Default
309                 flags = FORMAT_SHOW_DATE | FORMAT_NO_YEAR | FORMAT_ABBREV_MONTH;
310             }
311 
312             dateClause = DateTimeFormat.format(icuLocale, timeCalendar, flags,
313                     DisplayContext.CAPITALIZATION_FOR_BEGINNING_OF_SENTENCE);
314         }
315 
316         String timeClause = DateTimeFormat.format(icuLocale, timeCalendar, FORMAT_SHOW_TIME,
317                 DisplayContext.CAPITALIZATION_NONE);
318 
319         // icu4j also has other options available to control the capitalization. We are currently
320         // using
321         // the _NONE option only.
322         DisplayContext capitalizationContext = DisplayContext.CAPITALIZATION_NONE;
323 
324         // Combine the two clauses, such as '5 days ago, 10:50 AM'.
325         synchronized (CACHED_FORMATTERS) {
326             return getFormatter(icuLocale, style, capitalizationContext)
327                     .combineDateAndTime(dateClause, timeClause);
328         }
329     }
330 
331     /**
332      * getFormatter() caches the RelativeDateTimeFormatter instances based on the combination of
333      * localeName, sytle and capitalizationContext. It should always be used along with the action
334      * of the formatter in a synchronized block, because otherwise the formatter returned by
335      * getFormatter() may have been evicted by the time of the call to formatter->action().
336      */
getFormatter( ULocale locale, android.icu.text.RelativeDateTimeFormatter.Style style, DisplayContext displayContext)337     private static android.icu.text.RelativeDateTimeFormatter getFormatter(
338             ULocale locale, android.icu.text.RelativeDateTimeFormatter.Style style,
339             DisplayContext displayContext) {
340         String key = locale + "\t" + style + "\t" + displayContext;
341         android.icu.text.RelativeDateTimeFormatter formatter = CACHED_FORMATTERS.get(key);
342         if (formatter == null) {
343             formatter = android.icu.text.RelativeDateTimeFormatter.getInstance(
344                     locale, null, style, displayContext);
345             CACHED_FORMATTERS.put(key, formatter);
346         }
347         return formatter;
348     }
349 
350     // Return the date difference for the two times in a given timezone.
dayDistance(android.icu.util.TimeZone icuTimeZone, long startTime, long endTime)351     private static int dayDistance(android.icu.util.TimeZone icuTimeZone, long startTime,
352             long endTime) {
353         return julianDay(icuTimeZone, endTime) - julianDay(icuTimeZone, startTime);
354     }
355 
julianDay(android.icu.util.TimeZone icuTimeZone, long time)356     private static int julianDay(android.icu.util.TimeZone icuTimeZone, long time) {
357         long utcMs = time + icuTimeZone.getOffset(time);
358         return (int) (utcMs / DAY_IN_MS) + EPOCH_JULIAN_DAY;
359     }
360 }
361