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